Skip to main content

max / goingson

24.7 KB · 758 lines History Blame Raw
1 //! Input validation for domain objects.
2 //!
3 //! This module provides the [`Validate`] trait and implementations for
4 //! validating DTOs before persistence operations.
5
6 use crate::constants::{
7 MAX_CONTACT_DISPLAY_NAME_LENGTH, MAX_EVENT_TITLE_LENGTH, MAX_PROJECT_NAME_LENGTH,
8 MAX_SCHEDULED_DURATION_MINUTES, MAX_TASK_DESCRIPTION_LENGTH,
9 };
10 use crate::error::CoreError;
11 use crate::contact::{NewContact, UpdateContact};
12 use crate::models::{NewEvent, NewProject, NewTask, UpdateEvent, UpdateProject, UpdateTask};
13
14 /// Tag rules for GoingsOn: shallow hierarchy, no required semantic prefix.
15 const GO_TAG_CONFIG: tagtree::TagConfig = tagtree::TagConfig {
16 max_depth: 3,
17 max_length: 60,
18 semantic_depth: 0,
19 };
20
21 /// Validate a single tag against the GoingsOn config.
22 fn validate_tag(tag: &str) -> Result<(), CoreError> {
23 tagtree::validate_with(tag, &GO_TAG_CONFIG)
24 .map_err(|e| CoreError::validation("tags", e.0))
25 }
26
27 /// Validates that a required string field is non-empty and within a max length.
28 fn validate_required_string(field: &'static str, value: &str, max_len: usize) -> Result<(), CoreError> {
29 if value.trim().is_empty() {
30 return Err(CoreError::validation(field, "cannot be empty"));
31 }
32 // Fast path: byte length can't exceed char count, so if bytes fit, chars do too
33 if value.len() > max_len && value.chars().count() > max_len {
34 return Err(CoreError::validation(
35 field,
36 format!("must be {} characters or less", max_len),
37 ));
38 }
39 Ok(())
40 }
41
42 /// Validates an optional duration is positive and within a max.
43 fn validate_optional_duration(field: &'static str, value: Option<i32>, max: i32) -> Result<(), CoreError> {
44 if let Some(duration) = value {
45 if duration <= 0 {
46 return Err(CoreError::validation(field, "must be positive"));
47 }
48 if duration > max {
49 return Err(CoreError::validation(field, "cannot exceed 24 hours"));
50 }
51 }
52 Ok(())
53 }
54
55 /// Validates a slice of tags against the GoingsOn tag config.
56 fn validate_tags(tags: &[String]) -> Result<(), CoreError> {
57 for tag in tags {
58 validate_tag(tag)?;
59 }
60 Ok(())
61 }
62
63 /// A trait for types that can validate their own data before persistence.
64 ///
65 /// Call `.validate()` on DTOs (`NewTask`, `NewProject`, `NewEvent`, `UpdateTask`,
66 /// `UpdateProject`, `UpdateEvent`) before passing them to a repository. This centralizes business rules
67 /// (length limits, required fields, range checks) in the core crate so
68 /// they're enforced regardless of whether the caller is a Tauri command,
69 /// a plugin import, or a test.
70 ///
71 /// # Example
72 ///
73 /// ```rust,ignore
74 /// use goingson_core::{NewTask, Validate};
75 ///
76 /// let task = NewTask { description: "".to_string(), /* ... */ };
77 /// if let Err(e) = task.validate() {
78 /// println!("Invalid task: {}", e);
79 /// }
80 /// ```
81 pub trait Validate {
82 /// Validates the object, returning an error if invalid.
83 fn validate(&self) -> Result<(), CoreError>;
84 }
85
86 // Project validation: non-empty name within MAX_PROJECT_NAME_LENGTH.
87 impl Validate for NewProject {
88 fn validate(&self) -> Result<(), CoreError> {
89 validate_required_string("name", &self.name, MAX_PROJECT_NAME_LENGTH)
90 }
91 }
92
93 // Same rules as NewProject — updates must also pass validation.
94 impl Validate for UpdateProject {
95 fn validate(&self) -> Result<(), CoreError> {
96 validate_required_string("name", &self.name, MAX_PROJECT_NAME_LENGTH)
97 }
98 }
99
100 // Task validation: non-empty description, valid duration (positive, ≤24h),
101 // tags validated via tagtree (lowercase, dot-separated, max 3 levels, 60 chars).
102 impl Validate for NewTask {
103 fn validate(&self) -> Result<(), CoreError> {
104 validate_required_string("description", &self.description, MAX_TASK_DESCRIPTION_LENGTH)?;
105 validate_optional_duration("scheduled_duration", self.scheduled_duration, MAX_SCHEDULED_DURATION_MINUTES)?;
106 validate_tags(&self.tags)
107 }
108 }
109
110 // Same rules as NewTask — updates must also pass validation.
111 impl Validate for UpdateTask {
112 fn validate(&self) -> Result<(), CoreError> {
113 validate_required_string("description", &self.description, MAX_TASK_DESCRIPTION_LENGTH)?;
114 validate_optional_duration("scheduled_duration", self.scheduled_duration, MAX_SCHEDULED_DURATION_MINUTES)?;
115 validate_tags(&self.tags)
116 }
117 }
118
119 // Event validation: non-empty title, end_time must be after start_time.
120 impl Validate for NewEvent {
121 fn validate(&self) -> Result<(), CoreError> {
122 validate_required_string("title", &self.title, MAX_EVENT_TITLE_LENGTH)?;
123 if let Some(end) = self.end_time {
124 if end <= self.start_time {
125 return Err(CoreError::validation("end_time", "must be after start_time"));
126 }
127 }
128 Ok(())
129 }
130 }
131
132 // Same rules as NewEvent — updates must also pass validation.
133 impl Validate for UpdateEvent {
134 fn validate(&self) -> Result<(), CoreError> {
135 validate_required_string("title", &self.title, MAX_EVENT_TITLE_LENGTH)?;
136 if let Some(end) = self.end_time {
137 if end <= self.start_time {
138 return Err(CoreError::validation("end_time", "must be after start_time"));
139 }
140 }
141 Ok(())
142 }
143 }
144
145 // Contact validation: non-empty display_name, valid tags.
146 fn validate_contact_fields(display_name: &str, tags: &[String]) -> Result<(), CoreError> {
147 validate_required_string("display_name", display_name, MAX_CONTACT_DISPLAY_NAME_LENGTH)?;
148 validate_tags(tags)
149 }
150
151 impl Validate for NewContact {
152 fn validate(&self) -> Result<(), CoreError> {
153 validate_contact_fields(&self.display_name, &self.tags)
154 }
155 }
156
157 impl Validate for UpdateContact {
158 fn validate(&self) -> Result<(), CoreError> {
159 validate_contact_fields(&self.display_name, &self.tags)
160 }
161 }
162
163 #[cfg(test)]
164 mod tests {
165 use super::*;
166 use crate::models::{BlockType, Priority, ProjectStatus, ProjectType, Recurrence, UpdateEvent, UpdateProject};
167 use chrono::{Duration, Utc};
168
169 #[test]
170 fn test_new_project_valid() {
171 let project = NewProject {
172 name: "Test Project".to_string(),
173 description: "A test".to_string(),
174 project_type: ProjectType::SideProject,
175 status: ProjectStatus::Active,
176 };
177 assert!(project.validate().is_ok());
178 }
179
180 #[test]
181 fn test_new_project_empty_name() {
182 let project = NewProject {
183 name: " ".to_string(),
184 description: "A test".to_string(),
185 project_type: ProjectType::SideProject,
186 status: ProjectStatus::Active,
187 };
188 let err = project.validate().unwrap_err();
189 assert!(matches!(err, CoreError::Validation { field: "name", .. }));
190 }
191
192 #[test]
193 fn test_new_task_valid() {
194 let task = NewTask {
195 project_id: None,
196 description: "Do something".to_string(),
197 priority: Priority::Medium,
198 due: None,
199 tags: vec!["work".to_string()],
200 recurrence: Recurrence::None,
201 recurrence_rule: None,
202 urgency: 5.0,
203 source_email_id: None,
204 scheduled_start: None,
205 scheduled_duration: Some(60),
206 contact_id: None,
207 milestone_id: None,
208 estimated_minutes: None,
209 recurrence_parent_id: None,
210 };
211 assert!(task.validate().is_ok());
212 }
213
214 #[test]
215 fn test_new_task_empty_description() {
216 let task = NewTask {
217 project_id: None,
218 description: "".to_string(),
219 priority: Priority::Medium,
220 due: None,
221 tags: vec![],
222 recurrence: Recurrence::None,
223 recurrence_rule: None,
224 urgency: 5.0,
225 source_email_id: None,
226 scheduled_start: None,
227 scheduled_duration: None,
228 contact_id: None,
229 milestone_id: None,
230 estimated_minutes: None,
231 recurrence_parent_id: None,
232 };
233 let err = task.validate().unwrap_err();
234 assert!(matches!(err, CoreError::Validation { field: "description", .. }));
235 }
236
237 #[test]
238 fn test_new_task_negative_duration() {
239 let task = NewTask {
240 project_id: None,
241 description: "Do something".to_string(),
242 priority: Priority::Medium,
243 due: None,
244 tags: vec![],
245 recurrence: Recurrence::None,
246 recurrence_rule: None,
247 urgency: 5.0,
248 source_email_id: None,
249 scheduled_start: None,
250 scheduled_duration: Some(-30),
251 contact_id: None,
252 milestone_id: None,
253 estimated_minutes: None,
254 recurrence_parent_id: None,
255 };
256 let err = task.validate().unwrap_err();
257 assert!(matches!(err, CoreError::Validation { field: "scheduled_duration", .. }));
258 }
259
260 #[test]
261 fn test_new_event_valid() {
262 let now = Utc::now();
263 let event = NewEvent {
264 user_id: None,
265 project_id: None,
266 title: "Meeting".to_string(),
267 description: "Team standup".to_string(),
268 start_time: now,
269 end_time: Some(now + Duration::hours(1)),
270 location: None,
271 linked_task_id: None,
272 recurrence: Recurrence::None,
273 recurrence_rule: None,
274 contact_id: None,
275 block_type: None,
276 reminder_offsets_seconds: Vec::new(),
277 };
278 assert!(event.validate().is_ok());
279 }
280
281 #[test]
282 fn test_new_event_end_before_start() {
283 let now = Utc::now();
284 let event = NewEvent {
285 user_id: None,
286 project_id: None,
287 title: "Meeting".to_string(),
288 description: "".to_string(),
289 start_time: now,
290 end_time: Some(now - Duration::hours(1)),
291 location: None,
292 linked_task_id: None,
293 recurrence: Recurrence::None,
294 recurrence_rule: None,
295 contact_id: None,
296 block_type: None,
297 reminder_offsets_seconds: Vec::new(),
298 };
299 let err = event.validate().unwrap_err();
300 assert!(matches!(err, CoreError::Validation { field: "end_time", .. }));
301 }
302
303 #[test]
304 fn test_new_event_empty_title() {
305 let now = Utc::now();
306 let event = NewEvent {
307 user_id: None,
308 project_id: None,
309 title: "".to_string(),
310 description: "".to_string(),
311 start_time: now,
312 end_time: None,
313 location: None,
314 linked_task_id: None,
315 recurrence: Recurrence::None,
316 recurrence_rule: None,
317 contact_id: None,
318 block_type: None,
319 reminder_offsets_seconds: Vec::new(),
320 };
321 let err = event.validate().unwrap_err();
322 assert!(matches!(err, CoreError::Validation { field: "title", .. }));
323 }
324
325 #[test]
326 fn test_new_task_empty_tag() {
327 let task = NewTask {
328 project_id: None,
329 description: "Task with empty tag".to_string(),
330 priority: Priority::Medium,
331 due: None,
332 tags: vec!["valid".to_string(), "".to_string()],
333 recurrence: Recurrence::None,
334 recurrence_rule: None,
335 urgency: 5.0,
336 source_email_id: None,
337 scheduled_start: None,
338 scheduled_duration: None,
339 contact_id: None,
340 milestone_id: None,
341 estimated_minutes: None,
342 recurrence_parent_id: None,
343 };
344 let err = task.validate().unwrap_err();
345 assert!(matches!(err, CoreError::Validation { field: "tags", .. }));
346 }
347
348 #[test]
349 fn test_new_task_tag_too_long() {
350 let task = NewTask {
351 project_id: None,
352 description: "Task with long tag".to_string(),
353 priority: Priority::Medium,
354 due: None,
355 tags: vec!["a".repeat(61)], // exceeds tagtree max_length of 60
356 recurrence: Recurrence::None,
357 recurrence_rule: None,
358 urgency: 5.0,
359 source_email_id: None,
360 scheduled_start: None,
361 scheduled_duration: None,
362 contact_id: None,
363 milestone_id: None,
364 estimated_minutes: None,
365 recurrence_parent_id: None,
366 };
367 let err = task.validate().unwrap_err();
368 assert!(matches!(err, CoreError::Validation { field: "tags", .. }));
369 }
370
371 #[test]
372 fn test_new_task_duration_too_long() {
373 let task = NewTask {
374 project_id: None,
375 description: "Task with excessive duration".to_string(),
376 priority: Priority::Medium,
377 due: None,
378 tags: vec![],
379 recurrence: Recurrence::None,
380 recurrence_rule: None,
381 urgency: 5.0,
382 source_email_id: None,
383 scheduled_start: None,
384 scheduled_duration: Some(MAX_SCHEDULED_DURATION_MINUTES + 1),
385 contact_id: None,
386 milestone_id: None,
387 estimated_minutes: None,
388 recurrence_parent_id: None,
389 };
390 let err = task.validate().unwrap_err();
391 assert!(matches!(err, CoreError::Validation { field: "scheduled_duration", .. }));
392 }
393
394 #[test]
395 fn test_new_project_name_too_long() {
396 let project = NewProject {
397 name: "a".repeat(MAX_PROJECT_NAME_LENGTH + 1),
398 description: "".to_string(),
399 project_type: ProjectType::SideProject,
400 status: ProjectStatus::Active,
401 };
402 let err = project.validate().unwrap_err();
403 assert!(matches!(err, CoreError::Validation { field: "name", .. }));
404 }
405
406 #[test]
407 fn test_new_task_description_too_long() {
408 let task = NewTask {
409 project_id: None,
410 description: "a".repeat(MAX_TASK_DESCRIPTION_LENGTH + 1),
411 priority: Priority::Medium,
412 due: None,
413 tags: vec![],
414 recurrence: Recurrence::None,
415 recurrence_rule: None,
416 urgency: 5.0,
417 source_email_id: None,
418 scheduled_start: None,
419 scheduled_duration: None,
420 contact_id: None,
421 milestone_id: None,
422 estimated_minutes: None,
423 recurrence_parent_id: None,
424 };
425 let err = task.validate().unwrap_err();
426 assert!(matches!(err, CoreError::Validation { field: "description", .. }));
427 }
428
429 #[test]
430 fn test_new_event_title_too_long() {
431 let now = Utc::now();
432 let event = NewEvent {
433 user_id: None,
434 project_id: None,
435 title: "a".repeat(MAX_EVENT_TITLE_LENGTH + 1),
436 description: "".to_string(),
437 start_time: now,
438 end_time: None,
439 location: None,
440 linked_task_id: None,
441 recurrence: Recurrence::None,
442 recurrence_rule: None,
443 contact_id: None,
444 block_type: None,
445 reminder_offsets_seconds: Vec::new(),
446 };
447 let err = event.validate().unwrap_err();
448 assert!(matches!(err, CoreError::Validation { field: "title", .. }));
449 }
450
451 #[test]
452 fn test_new_event_end_equals_start() {
453 let now = Utc::now();
454 let event = NewEvent {
455 user_id: None,
456 project_id: None,
457 title: "Meeting".to_string(),
458 description: "".to_string(),
459 start_time: now,
460 end_time: Some(now), // Same as start
461 location: None,
462 linked_task_id: None,
463 recurrence: Recurrence::None,
464 recurrence_rule: None,
465 contact_id: None,
466 block_type: None,
467 reminder_offsets_seconds: Vec::new(),
468 };
469 let err = event.validate().unwrap_err();
470 assert!(matches!(err, CoreError::Validation { field: "end_time", .. }));
471 }
472
473 #[test]
474 fn test_update_task_valid() {
475 let task = UpdateTask {
476 project_id: None,
477 contact_id: None,
478 milestone_id: None,
479 description: "Updated task".to_string(),
480 status: crate::models::TaskStatus::Started,
481 priority: Priority::High,
482 due: Some(Utc::now() + Duration::days(1)),
483 tags: vec!["updated".to_string()],
484 recurrence: Recurrence::Weekly,
485 urgency: 7.0,
486 scheduled_start: None,
487 scheduled_duration: None,
488 estimated_minutes: None,
489 };
490 assert!(task.validate().is_ok());
491 }
492
493 #[test]
494 fn test_whitespace_only_fields_rejected() {
495 // Project with whitespace-only name
496 let project = NewProject {
497 name: " \t\n".to_string(),
498 description: "".to_string(),
499 project_type: ProjectType::SideProject,
500 status: ProjectStatus::Active,
501 };
502 assert!(project.validate().is_err());
503
504 // Task with whitespace-only description
505 let task = NewTask {
506 project_id: None,
507 description: "\n\t ".to_string(),
508 priority: Priority::Medium,
509 due: None,
510 tags: vec![],
511 recurrence: Recurrence::None,
512 recurrence_rule: None,
513 urgency: 5.0,
514 source_email_id: None,
515 scheduled_start: None,
516 scheduled_duration: None,
517 contact_id: None,
518 milestone_id: None,
519 estimated_minutes: None,
520 recurrence_parent_id: None,
521 };
522 assert!(task.validate().is_err());
523
524 // Event with whitespace-only title
525 let event = NewEvent {
526 user_id: None,
527 project_id: None,
528 title: " \t".to_string(),
529 description: "".to_string(),
530 start_time: Utc::now(),
531 end_time: None,
532 location: None,
533 linked_task_id: None,
534 recurrence: Recurrence::None,
535 recurrence_rule: None,
536 contact_id: None,
537 block_type: None,
538 reminder_offsets_seconds: Vec::new(),
539 };
540 assert!(event.validate().is_err());
541 }
542
543 // ---- UpdateProject validation tests ----
544
545 #[test]
546 fn test_update_project_valid() {
547 let project = UpdateProject {
548 name: "Renamed Project".to_string(),
549 description: "Updated description".to_string(),
550 project_type: ProjectType::Job,
551 status: ProjectStatus::Active,
552 };
553 assert!(project.validate().is_ok());
554 }
555
556 #[test]
557 fn test_update_project_empty_name() {
558 let project = UpdateProject {
559 name: " ".to_string(),
560 description: "".to_string(),
561 project_type: ProjectType::SideProject,
562 status: ProjectStatus::Active,
563 };
564 let err = project.validate().unwrap_err();
565 assert!(matches!(err, CoreError::Validation { field: "name", .. }));
566 }
567
568 #[test]
569 fn test_update_project_name_too_long() {
570 let project = UpdateProject {
571 name: "a".repeat(MAX_PROJECT_NAME_LENGTH + 1),
572 description: "".to_string(),
573 project_type: ProjectType::SideProject,
574 status: ProjectStatus::Active,
575 };
576 let err = project.validate().unwrap_err();
577 assert!(matches!(err, CoreError::Validation { field: "name", .. }));
578 }
579
580 // ---- UpdateEvent validation tests ----
581
582 #[test]
583 fn test_update_event_valid() {
584 let now = Utc::now();
585 let event = UpdateEvent {
586 project_id: None,
587 contact_id: None,
588 title: "Updated Meeting".to_string(),
589 description: "New notes".to_string(),
590 start_time: now,
591 end_time: Some(now + Duration::hours(2)),
592 location: Some("Room B".to_string()),
593 linked_task_id: None,
594 recurrence: Recurrence::None,
595 recurrence_rule: None,
596 block_type: Some(BlockType::Focus),
597 reminder_offsets_seconds: Vec::new(),
598 };
599 assert!(event.validate().is_ok());
600 }
601
602 #[test]
603 fn test_update_event_empty_title() {
604 let now = Utc::now();
605 let event = UpdateEvent {
606 project_id: None,
607 contact_id: None,
608 title: "".to_string(),
609 description: "".to_string(),
610 start_time: now,
611 end_time: None,
612 location: None,
613 linked_task_id: None,
614 recurrence: Recurrence::None,
615 recurrence_rule: None,
616 block_type: None,
617 reminder_offsets_seconds: Vec::new(),
618 };
619 let err = event.validate().unwrap_err();
620 assert!(matches!(err, CoreError::Validation { field: "title", .. }));
621 }
622
623 #[test]
624 fn test_update_event_title_too_long() {
625 let now = Utc::now();
626 let event = UpdateEvent {
627 project_id: None,
628 contact_id: None,
629 title: "a".repeat(MAX_EVENT_TITLE_LENGTH + 1),
630 description: "".to_string(),
631 start_time: now,
632 end_time: None,
633 location: None,
634 linked_task_id: None,
635 recurrence: Recurrence::None,
636 recurrence_rule: None,
637 block_type: None,
638 reminder_offsets_seconds: Vec::new(),
639 };
640 let err = event.validate().unwrap_err();
641 assert!(matches!(err, CoreError::Validation { field: "title", .. }));
642 }
643
644 #[test]
645 fn test_update_event_end_before_start() {
646 let now = Utc::now();
647 let event = UpdateEvent {
648 project_id: None,
649 contact_id: None,
650 title: "Meeting".to_string(),
651 description: "".to_string(),
652 start_time: now,
653 end_time: Some(now - Duration::hours(1)),
654 location: None,
655 linked_task_id: None,
656 recurrence: Recurrence::None,
657 recurrence_rule: None,
658 block_type: None,
659 reminder_offsets_seconds: Vec::new(),
660 };
661 let err = event.validate().unwrap_err();
662 assert!(matches!(err, CoreError::Validation { field: "end_time", .. }));
663 }
664
665 #[test]
666 fn test_update_event_end_equals_start() {
667 let now = Utc::now();
668 let event = UpdateEvent {
669 project_id: None,
670 contact_id: None,
671 title: "Meeting".to_string(),
672 description: "".to_string(),
673 start_time: now,
674 end_time: Some(now),
675 location: None,
676 linked_task_id: None,
677 recurrence: Recurrence::None,
678 recurrence_rule: None,
679 block_type: None,
680 reminder_offsets_seconds: Vec::new(),
681 };
682 let err = event.validate().unwrap_err();
683 assert!(matches!(err, CoreError::Validation { field: "end_time", .. }));
684 }
685
686 // ---- Contact validation tests ----
687
688 fn make_new_contact(name: &str) -> NewContact {
689 NewContact {
690 display_name: name.to_string(),
691 nickname: None,
692 company: None,
693 title: None,
694 notes: String::new(),
695 tags: vec![],
696 birthday: None,
697 timezone: None,
698 is_implicit: false,
699 }
700 }
701
702 #[test]
703 fn test_new_contact_valid() {
704 assert!(make_new_contact("Alice").validate().is_ok());
705 }
706
707 #[test]
708 fn test_new_contact_empty_name() {
709 let err = make_new_contact(" ").validate().unwrap_err();
710 assert!(matches!(err, CoreError::Validation { field: "display_name", .. }));
711 }
712
713 #[test]
714 fn test_new_contact_name_too_long() {
715 let err = make_new_contact(&"a".repeat(256)).validate().unwrap_err();
716 assert!(matches!(err, CoreError::Validation { field: "display_name", .. }));
717 }
718
719 #[test]
720 fn test_new_contact_empty_tag() {
721 let mut c = make_new_contact("Bob");
722 c.tags = vec!["valid".to_string(), "".to_string()];
723 let err = c.validate().unwrap_err();
724 assert!(matches!(err, CoreError::Validation { field: "tags", .. }));
725 }
726
727 #[test]
728 fn test_update_contact_valid() {
729 let update = UpdateContact {
730 display_name: "Updated".to_string(),
731 nickname: None,
732 company: None,
733 title: None,
734 notes: String::new(),
735 tags: vec!["ok".to_string()],
736 birthday: None,
737 timezone: None,
738 };
739 assert!(update.validate().is_ok());
740 }
741
742 #[test]
743 fn test_update_contact_empty_name() {
744 let update = UpdateContact {
745 display_name: "".to_string(),
746 nickname: None,
747 company: None,
748 title: None,
749 notes: String::new(),
750 tags: vec![],
751 birthday: None,
752 timezone: None,
753 };
754 let err = update.validate().unwrap_err();
755 assert!(matches!(err, CoreError::Validation { field: "display_name", .. }));
756 }
757 }
758