//! Input validation for domain objects. //! //! This module provides the [`Validate`] trait and implementations for //! validating DTOs before persistence operations. use crate::constants::{ MAX_CONTACT_DISPLAY_NAME_LENGTH, MAX_EVENT_TITLE_LENGTH, MAX_PROJECT_NAME_LENGTH, MAX_SCHEDULED_DURATION_MINUTES, MAX_TASK_DESCRIPTION_LENGTH, }; use crate::error::CoreError; use crate::contact::{NewContact, UpdateContact}; use crate::models::{NewEvent, NewProject, NewTask, UpdateEvent, UpdateProject, UpdateTask}; /// Tag rules for GoingsOn: shallow hierarchy, no required semantic prefix. const GO_TAG_CONFIG: tagtree::TagConfig = tagtree::TagConfig { max_depth: 3, max_length: 60, semantic_depth: 0, }; /// Validate a single tag against the GoingsOn config. fn validate_tag(tag: &str) -> Result<(), CoreError> { tagtree::validate_with(tag, &GO_TAG_CONFIG) .map_err(|e| CoreError::validation("tags", e.0)) } /// Validates that a required string field is non-empty and within a max length. fn validate_required_string(field: &'static str, value: &str, max_len: usize) -> Result<(), CoreError> { if value.trim().is_empty() { return Err(CoreError::validation(field, "cannot be empty")); } // Fast path: byte length can't exceed char count, so if bytes fit, chars do too if value.len() > max_len && value.chars().count() > max_len { return Err(CoreError::validation( field, format!("must be {} characters or less", max_len), )); } Ok(()) } /// Validates an optional duration is positive and within a max. fn validate_optional_duration(field: &'static str, value: Option, max: i32) -> Result<(), CoreError> { if let Some(duration) = value { if duration <= 0 { return Err(CoreError::validation(field, "must be positive")); } if duration > max { return Err(CoreError::validation(field, "cannot exceed 24 hours")); } } Ok(()) } /// Validates a slice of tags against the GoingsOn tag config. fn validate_tags(tags: &[String]) -> Result<(), CoreError> { for tag in tags { validate_tag(tag)?; } Ok(()) } /// A trait for types that can validate their own data before persistence. /// /// Call `.validate()` on DTOs (`NewTask`, `NewProject`, `NewEvent`, `UpdateTask`, /// `UpdateProject`, `UpdateEvent`) before passing them to a repository. This centralizes business rules /// (length limits, required fields, range checks) in the core crate so /// they're enforced regardless of whether the caller is a Tauri command, /// a plugin import, or a test. /// /// # Example /// /// ```rust,ignore /// use goingson_core::{NewTask, Validate}; /// /// let task = NewTask { description: "".to_string(), /* ... */ }; /// if let Err(e) = task.validate() { /// println!("Invalid task: {}", e); /// } /// ``` pub trait Validate { /// Validates the object, returning an error if invalid. fn validate(&self) -> Result<(), CoreError>; } // Project validation: non-empty name within MAX_PROJECT_NAME_LENGTH. impl Validate for NewProject { fn validate(&self) -> Result<(), CoreError> { validate_required_string("name", &self.name, MAX_PROJECT_NAME_LENGTH) } } // Same rules as NewProject — updates must also pass validation. impl Validate for UpdateProject { fn validate(&self) -> Result<(), CoreError> { validate_required_string("name", &self.name, MAX_PROJECT_NAME_LENGTH) } } // Task validation: non-empty description, valid duration (positive, ≤24h), // tags validated via tagtree (lowercase, dot-separated, max 3 levels, 60 chars). impl Validate for NewTask { fn validate(&self) -> Result<(), CoreError> { validate_required_string("description", &self.description, MAX_TASK_DESCRIPTION_LENGTH)?; validate_optional_duration("scheduled_duration", self.scheduled_duration, MAX_SCHEDULED_DURATION_MINUTES)?; validate_tags(&self.tags) } } // Same rules as NewTask — updates must also pass validation. impl Validate for UpdateTask { fn validate(&self) -> Result<(), CoreError> { validate_required_string("description", &self.description, MAX_TASK_DESCRIPTION_LENGTH)?; validate_optional_duration("scheduled_duration", self.scheduled_duration, MAX_SCHEDULED_DURATION_MINUTES)?; validate_tags(&self.tags) } } // Event validation: non-empty title, end_time must be after start_time. impl Validate for NewEvent { fn validate(&self) -> Result<(), CoreError> { validate_required_string("title", &self.title, MAX_EVENT_TITLE_LENGTH)?; if let Some(end) = self.end_time { if end <= self.start_time { return Err(CoreError::validation("end_time", "must be after start_time")); } } Ok(()) } } // Same rules as NewEvent — updates must also pass validation. impl Validate for UpdateEvent { fn validate(&self) -> Result<(), CoreError> { validate_required_string("title", &self.title, MAX_EVENT_TITLE_LENGTH)?; if let Some(end) = self.end_time { if end <= self.start_time { return Err(CoreError::validation("end_time", "must be after start_time")); } } Ok(()) } } // Contact validation: non-empty display_name, valid tags. fn validate_contact_fields(display_name: &str, tags: &[String]) -> Result<(), CoreError> { validate_required_string("display_name", display_name, MAX_CONTACT_DISPLAY_NAME_LENGTH)?; validate_tags(tags) } impl Validate for NewContact { fn validate(&self) -> Result<(), CoreError> { validate_contact_fields(&self.display_name, &self.tags) } } impl Validate for UpdateContact { fn validate(&self) -> Result<(), CoreError> { validate_contact_fields(&self.display_name, &self.tags) } } #[cfg(test)] mod tests { use super::*; use crate::models::{BlockType, Priority, ProjectStatus, ProjectType, Recurrence, UpdateEvent, UpdateProject}; use chrono::{Duration, Utc}; #[test] fn test_new_project_valid() { let project = NewProject { name: "Test Project".to_string(), description: "A test".to_string(), project_type: ProjectType::SideProject, status: ProjectStatus::Active, }; assert!(project.validate().is_ok()); } #[test] fn test_new_project_empty_name() { let project = NewProject { name: " ".to_string(), description: "A test".to_string(), project_type: ProjectType::SideProject, status: ProjectStatus::Active, }; let err = project.validate().unwrap_err(); assert!(matches!(err, CoreError::Validation { field: "name", .. })); } #[test] fn test_new_task_valid() { let task = NewTask { project_id: None, description: "Do something".to_string(), priority: Priority::Medium, due: None, tags: vec!["work".to_string()], recurrence: Recurrence::None, recurrence_rule: None, urgency: 5.0, source_email_id: None, scheduled_start: None, scheduled_duration: Some(60), contact_id: None, milestone_id: None, estimated_minutes: None, recurrence_parent_id: None, }; assert!(task.validate().is_ok()); } #[test] fn test_new_task_empty_description() { let task = NewTask { project_id: None, description: "".to_string(), priority: Priority::Medium, due: None, tags: vec![], recurrence: Recurrence::None, recurrence_rule: None, urgency: 5.0, source_email_id: None, scheduled_start: None, scheduled_duration: None, contact_id: None, milestone_id: None, estimated_minutes: None, recurrence_parent_id: None, }; let err = task.validate().unwrap_err(); assert!(matches!(err, CoreError::Validation { field: "description", .. })); } #[test] fn test_new_task_negative_duration() { let task = NewTask { project_id: None, description: "Do something".to_string(), priority: Priority::Medium, due: None, tags: vec![], recurrence: Recurrence::None, recurrence_rule: None, urgency: 5.0, source_email_id: None, scheduled_start: None, scheduled_duration: Some(-30), contact_id: None, milestone_id: None, estimated_minutes: None, recurrence_parent_id: None, }; let err = task.validate().unwrap_err(); assert!(matches!(err, CoreError::Validation { field: "scheduled_duration", .. })); } #[test] fn test_new_event_valid() { let now = Utc::now(); let event = NewEvent { user_id: None, project_id: None, title: "Meeting".to_string(), description: "Team standup".to_string(), start_time: now, end_time: Some(now + Duration::hours(1)), location: None, linked_task_id: None, recurrence: Recurrence::None, recurrence_rule: None, contact_id: None, block_type: None, reminder_offsets_seconds: Vec::new(), }; assert!(event.validate().is_ok()); } #[test] fn test_new_event_end_before_start() { let now = Utc::now(); let event = NewEvent { user_id: None, project_id: None, title: "Meeting".to_string(), description: "".to_string(), start_time: now, end_time: Some(now - Duration::hours(1)), location: None, linked_task_id: None, recurrence: Recurrence::None, recurrence_rule: None, contact_id: None, block_type: None, reminder_offsets_seconds: Vec::new(), }; let err = event.validate().unwrap_err(); assert!(matches!(err, CoreError::Validation { field: "end_time", .. })); } #[test] fn test_new_event_empty_title() { let now = Utc::now(); let event = NewEvent { user_id: None, project_id: None, title: "".to_string(), description: "".to_string(), start_time: now, end_time: None, location: None, linked_task_id: None, recurrence: Recurrence::None, recurrence_rule: None, contact_id: None, block_type: None, reminder_offsets_seconds: Vec::new(), }; let err = event.validate().unwrap_err(); assert!(matches!(err, CoreError::Validation { field: "title", .. })); } #[test] fn test_new_task_empty_tag() { let task = NewTask { project_id: None, description: "Task with empty tag".to_string(), priority: Priority::Medium, due: None, tags: vec!["valid".to_string(), "".to_string()], recurrence: Recurrence::None, recurrence_rule: None, urgency: 5.0, source_email_id: None, scheduled_start: None, scheduled_duration: None, contact_id: None, milestone_id: None, estimated_minutes: None, recurrence_parent_id: None, }; let err = task.validate().unwrap_err(); assert!(matches!(err, CoreError::Validation { field: "tags", .. })); } #[test] fn test_new_task_tag_too_long() { let task = NewTask { project_id: None, description: "Task with long tag".to_string(), priority: Priority::Medium, due: None, tags: vec!["a".repeat(61)], // exceeds tagtree max_length of 60 recurrence: Recurrence::None, recurrence_rule: None, urgency: 5.0, source_email_id: None, scheduled_start: None, scheduled_duration: None, contact_id: None, milestone_id: None, estimated_minutes: None, recurrence_parent_id: None, }; let err = task.validate().unwrap_err(); assert!(matches!(err, CoreError::Validation { field: "tags", .. })); } #[test] fn test_new_task_duration_too_long() { let task = NewTask { project_id: None, description: "Task with excessive duration".to_string(), priority: Priority::Medium, due: None, tags: vec![], recurrence: Recurrence::None, recurrence_rule: None, urgency: 5.0, source_email_id: None, scheduled_start: None, scheduled_duration: Some(MAX_SCHEDULED_DURATION_MINUTES + 1), contact_id: None, milestone_id: None, estimated_minutes: None, recurrence_parent_id: None, }; let err = task.validate().unwrap_err(); assert!(matches!(err, CoreError::Validation { field: "scheduled_duration", .. })); } #[test] fn test_new_project_name_too_long() { let project = NewProject { name: "a".repeat(MAX_PROJECT_NAME_LENGTH + 1), description: "".to_string(), project_type: ProjectType::SideProject, status: ProjectStatus::Active, }; let err = project.validate().unwrap_err(); assert!(matches!(err, CoreError::Validation { field: "name", .. })); } #[test] fn test_new_task_description_too_long() { let task = NewTask { project_id: None, description: "a".repeat(MAX_TASK_DESCRIPTION_LENGTH + 1), priority: Priority::Medium, due: None, tags: vec![], recurrence: Recurrence::None, recurrence_rule: None, urgency: 5.0, source_email_id: None, scheduled_start: None, scheduled_duration: None, contact_id: None, milestone_id: None, estimated_minutes: None, recurrence_parent_id: None, }; let err = task.validate().unwrap_err(); assert!(matches!(err, CoreError::Validation { field: "description", .. })); } #[test] fn test_new_event_title_too_long() { let now = Utc::now(); let event = NewEvent { user_id: None, project_id: None, title: "a".repeat(MAX_EVENT_TITLE_LENGTH + 1), description: "".to_string(), start_time: now, end_time: None, location: None, linked_task_id: None, recurrence: Recurrence::None, recurrence_rule: None, contact_id: None, block_type: None, reminder_offsets_seconds: Vec::new(), }; let err = event.validate().unwrap_err(); assert!(matches!(err, CoreError::Validation { field: "title", .. })); } #[test] fn test_new_event_end_equals_start() { let now = Utc::now(); let event = NewEvent { user_id: None, project_id: None, title: "Meeting".to_string(), description: "".to_string(), start_time: now, end_time: Some(now), // Same as start location: None, linked_task_id: None, recurrence: Recurrence::None, recurrence_rule: None, contact_id: None, block_type: None, reminder_offsets_seconds: Vec::new(), }; let err = event.validate().unwrap_err(); assert!(matches!(err, CoreError::Validation { field: "end_time", .. })); } #[test] fn test_update_task_valid() { let task = UpdateTask { project_id: None, contact_id: None, milestone_id: None, description: "Updated task".to_string(), status: crate::models::TaskStatus::Started, priority: Priority::High, due: Some(Utc::now() + Duration::days(1)), tags: vec!["updated".to_string()], recurrence: Recurrence::Weekly, urgency: 7.0, scheduled_start: None, scheduled_duration: None, estimated_minutes: None, }; assert!(task.validate().is_ok()); } #[test] fn test_whitespace_only_fields_rejected() { // Project with whitespace-only name let project = NewProject { name: " \t\n".to_string(), description: "".to_string(), project_type: ProjectType::SideProject, status: ProjectStatus::Active, }; assert!(project.validate().is_err()); // Task with whitespace-only description let task = NewTask { project_id: None, description: "\n\t ".to_string(), priority: Priority::Medium, due: None, tags: vec![], recurrence: Recurrence::None, recurrence_rule: None, urgency: 5.0, source_email_id: None, scheduled_start: None, scheduled_duration: None, contact_id: None, milestone_id: None, estimated_minutes: None, recurrence_parent_id: None, }; assert!(task.validate().is_err()); // Event with whitespace-only title let event = NewEvent { user_id: None, project_id: None, title: " \t".to_string(), description: "".to_string(), start_time: Utc::now(), end_time: None, location: None, linked_task_id: None, recurrence: Recurrence::None, recurrence_rule: None, contact_id: None, block_type: None, reminder_offsets_seconds: Vec::new(), }; assert!(event.validate().is_err()); } // ---- UpdateProject validation tests ---- #[test] fn test_update_project_valid() { let project = UpdateProject { name: "Renamed Project".to_string(), description: "Updated description".to_string(), project_type: ProjectType::Job, status: ProjectStatus::Active, }; assert!(project.validate().is_ok()); } #[test] fn test_update_project_empty_name() { let project = UpdateProject { name: " ".to_string(), description: "".to_string(), project_type: ProjectType::SideProject, status: ProjectStatus::Active, }; let err = project.validate().unwrap_err(); assert!(matches!(err, CoreError::Validation { field: "name", .. })); } #[test] fn test_update_project_name_too_long() { let project = UpdateProject { name: "a".repeat(MAX_PROJECT_NAME_LENGTH + 1), description: "".to_string(), project_type: ProjectType::SideProject, status: ProjectStatus::Active, }; let err = project.validate().unwrap_err(); assert!(matches!(err, CoreError::Validation { field: "name", .. })); } // ---- UpdateEvent validation tests ---- #[test] fn test_update_event_valid() { let now = Utc::now(); let event = UpdateEvent { project_id: None, contact_id: None, title: "Updated Meeting".to_string(), description: "New notes".to_string(), start_time: now, end_time: Some(now + Duration::hours(2)), location: Some("Room B".to_string()), linked_task_id: None, recurrence: Recurrence::None, recurrence_rule: None, block_type: Some(BlockType::Focus), reminder_offsets_seconds: Vec::new(), }; assert!(event.validate().is_ok()); } #[test] fn test_update_event_empty_title() { let now = Utc::now(); let event = UpdateEvent { project_id: None, contact_id: None, title: "".to_string(), description: "".to_string(), start_time: now, end_time: None, location: None, linked_task_id: None, recurrence: Recurrence::None, recurrence_rule: None, block_type: None, reminder_offsets_seconds: Vec::new(), }; let err = event.validate().unwrap_err(); assert!(matches!(err, CoreError::Validation { field: "title", .. })); } #[test] fn test_update_event_title_too_long() { let now = Utc::now(); let event = UpdateEvent { project_id: None, contact_id: None, title: "a".repeat(MAX_EVENT_TITLE_LENGTH + 1), description: "".to_string(), start_time: now, end_time: None, location: None, linked_task_id: None, recurrence: Recurrence::None, recurrence_rule: None, block_type: None, reminder_offsets_seconds: Vec::new(), }; let err = event.validate().unwrap_err(); assert!(matches!(err, CoreError::Validation { field: "title", .. })); } #[test] fn test_update_event_end_before_start() { let now = Utc::now(); let event = UpdateEvent { project_id: None, contact_id: None, title: "Meeting".to_string(), description: "".to_string(), start_time: now, end_time: Some(now - Duration::hours(1)), location: None, linked_task_id: None, recurrence: Recurrence::None, recurrence_rule: None, block_type: None, reminder_offsets_seconds: Vec::new(), }; let err = event.validate().unwrap_err(); assert!(matches!(err, CoreError::Validation { field: "end_time", .. })); } #[test] fn test_update_event_end_equals_start() { let now = Utc::now(); let event = UpdateEvent { project_id: None, contact_id: None, title: "Meeting".to_string(), description: "".to_string(), start_time: now, end_time: Some(now), location: None, linked_task_id: None, recurrence: Recurrence::None, recurrence_rule: None, block_type: None, reminder_offsets_seconds: Vec::new(), }; let err = event.validate().unwrap_err(); assert!(matches!(err, CoreError::Validation { field: "end_time", .. })); } // ---- Contact validation tests ---- fn make_new_contact(name: &str) -> NewContact { NewContact { display_name: name.to_string(), nickname: None, company: None, title: None, notes: String::new(), tags: vec![], birthday: None, timezone: None, is_implicit: false, } } #[test] fn test_new_contact_valid() { assert!(make_new_contact("Alice").validate().is_ok()); } #[test] fn test_new_contact_empty_name() { let err = make_new_contact(" ").validate().unwrap_err(); assert!(matches!(err, CoreError::Validation { field: "display_name", .. })); } #[test] fn test_new_contact_name_too_long() { let err = make_new_contact(&"a".repeat(256)).validate().unwrap_err(); assert!(matches!(err, CoreError::Validation { field: "display_name", .. })); } #[test] fn test_new_contact_empty_tag() { let mut c = make_new_contact("Bob"); c.tags = vec!["valid".to_string(), "".to_string()]; let err = c.validate().unwrap_err(); assert!(matches!(err, CoreError::Validation { field: "tags", .. })); } #[test] fn test_update_contact_valid() { let update = UpdateContact { display_name: "Updated".to_string(), nickname: None, company: None, title: None, notes: String::new(), tags: vec!["ok".to_string()], birthday: None, timezone: None, }; assert!(update.validate().is_ok()); } #[test] fn test_update_contact_empty_name() { let update = UpdateContact { display_name: "".to_string(), nickname: None, company: None, title: None, notes: String::new(), tags: vec![], birthday: None, timezone: None, }; let err = update.validate().unwrap_err(); assert!(matches!(err, CoreError::Validation { field: "display_name", .. })); } }