1use std::fmt;
2
3use thiserror::Error;
4use uuid::Uuid;
5
6#[derive(Debug, Clone)]
11pub struct AmbiguousMatch {
12 pub label: String,
18 pub id: Uuid,
20}
21
22impl fmt::Display for AmbiguousMatch {
23 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24 write!(f, "{} ({})", self.label, self.id)
25 }
26}
27
28#[derive(Debug, Clone)]
31pub struct BatchResolutionFailure {
32 pub raw_input: String,
34 pub cause: BatchResolutionCause,
36}
37
38impl fmt::Display for BatchResolutionFailure {
39 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40 write!(f, "'{}' ({})", self.raw_input, self.cause)
41 }
42}
43
44#[derive(Debug, Clone)]
46pub enum BatchResolutionCause {
47 NotFound,
49 Ambiguous(Vec<AmbiguousMatch>),
52}
53
54impl fmt::Display for BatchResolutionCause {
55 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56 match self {
57 Self::NotFound => write!(f, "not found"),
58 Self::Ambiguous(matches) => {
59 f.write_str("ambiguous: ")?;
60 for (i, m) in matches.iter().enumerate() {
61 if i > 0 {
62 f.write_str(", ")?;
63 }
64 write!(f, "{}", m)?;
65 }
66 Ok(())
67 }
68 }
69 }
70}
71
72#[derive(Error, Debug)]
73pub enum DependencyError {
74 #[error("cycle detected: adding this edge would create a circular dependency")]
75 CycleDetected,
76 #[error("self-reference not allowed")]
77 SelfReference,
78 #[error("edge not found")]
79 EdgeNotFound,
80 #[error("edge already exists between the two cards")]
81 DuplicateEdge,
82}
83
84#[derive(Error, Debug)]
85pub enum DomainError {
86 #[error("{entity} {id} not found")]
92 NotFound { entity: &'static str, id: Uuid },
93
94 #[error("{}", DomainError::fmt_not_found_by_name(entity, name, available))]
99 NotFoundByName {
100 entity: &'static str,
101 name: String,
102 available: Vec<String>,
103 },
104
105 #[error("{}", DomainError::fmt_ambiguous(entity, name, matches))]
109 Ambiguous {
110 entity: &'static str,
111 name: String,
112 matches: Vec<AmbiguousMatch>,
113 },
114
115 #[error("{}", DomainError::fmt_batch_resolution_failed(entity, failures))]
119 BatchResolutionFailed {
120 entity: &'static str,
121 failures: Vec<BatchResolutionFailure>,
122 },
123
124 #[error("validation error: {0}")]
125 Validation(String),
126
127 #[error(transparent)]
128 Dependency(#[from] DependencyError),
129
130 #[error("column {column_id} has reached its WIP limit of {limit}")]
131 WipLimitExceeded { column_id: Uuid, limit: u32 },
132
133 #[error(
134 "sprint {sprint_id} belongs to board {sprint_board} but card is being created on board {card_board}"
135 )]
136 SprintBoardMismatch {
137 sprint_id: Uuid,
138 sprint_board: Uuid,
139 card_board: Uuid,
140 },
141}
142
143impl DomainError {
144 fn fmt_not_found_by_name(entity: &str, name: &str, available: &[String]) -> String {
147 if available.is_empty() {
148 format!("{} '{}' not found", entity, name)
149 } else {
150 format!(
151 "{} '{}' not found. Available: {}",
152 entity,
153 name,
154 available
155 .iter()
156 .map(|s| format!("'{}'", s))
157 .collect::<Vec<_>>()
158 .join(", ")
159 )
160 }
161 }
162
163 fn fmt_ambiguous(entity: &str, name: &str, matches: &[AmbiguousMatch]) -> String {
164 let rendered = matches
165 .iter()
166 .map(ToString::to_string)
167 .collect::<Vec<_>>()
168 .join(", ");
169 format!("{} '{}' is ambiguous: {}.", entity, name, rendered)
170 }
171
172 fn fmt_batch_resolution_failed(entity: &str, failures: &[BatchResolutionFailure]) -> String {
173 let parts = failures
174 .iter()
175 .map(ToString::to_string)
176 .collect::<Vec<_>>()
177 .join(", ");
178 format!(
179 "Could not resolve {} {}: {}",
180 failures.len(),
181 pluralize(entity, failures.len()),
182 parts
183 )
184 }
185
186 pub fn wip_limit_exceeded(column_id: Uuid, limit: u32) -> Self {
187 Self::WipLimitExceeded { column_id, limit }
188 }
189}
190
191#[derive(Error, Debug)]
192pub enum KanbanError {
193 #[error(transparent)]
194 Domain(#[from] DomainError),
195
196 #[error("IO error: {0}")]
197 Io(#[from] std::io::Error),
198
199 #[error("serialization error: {0}")]
200 Serialization(String),
201
202 #[error("file conflict: {path} was modified by another instance")]
203 ConflictDetected {
204 path: String,
205 #[source]
206 source: Option<Box<dyn std::error::Error + Send + Sync>>,
207 },
208
209 #[error("database error: {0}")]
210 Database(String),
211
212 #[error("internal error: {0}")]
213 Internal(String),
214
215 #[error(
216 "file format v{file_version} is newer than this binary's max v{binary_max}; \
217 please upgrade kanban"
218 )]
219 UnsupportedFutureVersion { file_version: u32, binary_max: u32 },
220}
221
222fn pluralize(noun: &str, count: usize) -> String {
225 if count == 1 {
226 noun.to_string()
227 } else {
228 format!("{}s", noun)
229 }
230}
231
232pub type KanbanResult<T> = Result<T, KanbanError>;
233
234impl KanbanError {
235 pub fn not_found(entity: &'static str, id: Uuid) -> Self {
236 Self::Domain(DomainError::NotFound { entity, id })
237 }
238
239 pub fn not_found_by_name(
240 entity: &'static str,
241 name: impl Into<String>,
242 available: Vec<String>,
243 ) -> Self {
244 Self::Domain(DomainError::NotFoundByName {
245 entity,
246 name: name.into(),
247 available,
248 })
249 }
250
251 pub fn ambiguous(
252 entity: &'static str,
253 name: impl Into<String>,
254 matches: Vec<AmbiguousMatch>,
255 ) -> Self {
256 Self::Domain(DomainError::Ambiguous {
257 entity,
258 name: name.into(),
259 matches,
260 })
261 }
262
263 pub fn batch_resolution_failed(
264 entity: &'static str,
265 failures: Vec<BatchResolutionFailure>,
266 ) -> Self {
267 Self::Domain(DomainError::BatchResolutionFailed { entity, failures })
268 }
269
270 pub fn validation(msg: impl Into<String>) -> Self {
271 Self::Domain(DomainError::Validation(msg.into()))
272 }
273
274 pub fn is_not_found(&self) -> bool {
276 matches!(
277 self,
278 KanbanError::Domain(DomainError::NotFound { .. })
279 | KanbanError::Domain(DomainError::NotFoundByName { .. })
280 )
281 }
282
283 pub fn is_not_found_by_name(&self) -> bool {
284 matches!(
285 self,
286 KanbanError::Domain(DomainError::NotFoundByName { .. })
287 )
288 }
289
290 pub fn is_ambiguous(&self) -> bool {
291 matches!(self, KanbanError::Domain(DomainError::Ambiguous { .. }))
292 }
293
294 pub fn is_batch_resolution_failed(&self) -> bool {
295 matches!(
296 self,
297 KanbanError::Domain(DomainError::BatchResolutionFailed { .. })
298 )
299 }
300
301 pub fn is_validation(&self) -> bool {
302 matches!(self, KanbanError::Domain(DomainError::Validation(_)))
303 }
304
305 pub fn is_cycle_detected(&self) -> bool {
306 matches!(
307 self,
308 KanbanError::Domain(DomainError::Dependency(DependencyError::CycleDetected))
309 )
310 }
311
312 pub fn is_self_reference(&self) -> bool {
313 matches!(
314 self,
315 KanbanError::Domain(DomainError::Dependency(DependencyError::SelfReference))
316 )
317 }
318
319 pub fn is_edge_not_found(&self) -> bool {
320 matches!(
321 self,
322 KanbanError::Domain(DomainError::Dependency(DependencyError::EdgeNotFound))
323 )
324 }
325
326 pub fn is_duplicate_edge(&self) -> bool {
327 matches!(
328 self,
329 KanbanError::Domain(DomainError::Dependency(DependencyError::DuplicateEdge))
330 )
331 }
332
333 pub fn is_conflict_detected(&self) -> bool {
334 matches!(self, KanbanError::ConflictDetected { .. })
335 }
336
337 pub fn is_wip_limit_exceeded(&self) -> bool {
338 matches!(
339 self,
340 KanbanError::Domain(DomainError::WipLimitExceeded { .. })
341 )
342 }
343
344 pub fn is_sprint_board_mismatch(&self) -> bool {
345 matches!(
346 self,
347 KanbanError::Domain(DomainError::SprintBoardMismatch { .. })
348 )
349 }
350
351 pub fn is_unsupported_future_version(&self) -> bool {
352 matches!(self, KanbanError::UnsupportedFutureVersion { .. })
353 }
354
355 pub fn serialization(msg: impl Into<String>) -> Self {
356 Self::Serialization(msg.into())
357 }
358}
359
360impl From<DependencyError> for KanbanError {
361 fn from(e: DependencyError) -> Self {
362 KanbanError::Domain(DomainError::Dependency(e))
363 }
364}
365
366impl From<kanban_core::CoreError> for KanbanError {
367 fn from(e: kanban_core::CoreError) -> Self {
368 match e {
369 kanban_core::CoreError::Validation(msg) => KanbanError::validation(msg),
370 kanban_core::CoreError::Config(msg) => KanbanError::Internal(msg),
371 }
372 }
373}
374
375#[cfg(test)]
376mod tests {
377 use super::*;
378 use uuid::Uuid;
379
380 #[test]
381 fn test_is_not_found_returns_true_for_card_not_found() {
382 let err = KanbanError::not_found("Card", Uuid::new_v4());
383 assert!(err.is_not_found());
384 }
385
386 #[test]
387 fn test_is_not_found_returns_false_for_validation_error() {
388 let err = KanbanError::validation("bad input");
389 assert!(!err.is_not_found());
390 }
391
392 #[test]
393 fn test_is_validation_returns_true_for_validation_error() {
394 let err = KanbanError::validation("bad input");
395 assert!(err.is_validation());
396 }
397
398 #[test]
399 fn test_is_cycle_detected_returns_true() {
400 let err = KanbanError::from(DependencyError::CycleDetected);
401 assert!(err.is_cycle_detected());
402 }
403
404 #[test]
405 fn test_is_self_reference_returns_true() {
406 let err = KanbanError::from(DependencyError::SelfReference);
407 assert!(err.is_self_reference());
408 }
409
410 #[test]
411 fn test_is_edge_not_found_returns_true() {
412 let err = KanbanError::from(DependencyError::EdgeNotFound);
413 assert!(err.is_edge_not_found());
414 }
415
416 #[test]
417 fn test_is_self_reference_returns_false_for_other_error() {
418 let err = KanbanError::not_found("Card", Uuid::new_v4());
419 assert!(!err.is_self_reference());
420 }
421
422 #[test]
423 fn test_is_edge_not_found_returns_false_for_other_error() {
424 let err = KanbanError::not_found("Card", Uuid::new_v4());
425 assert!(!err.is_edge_not_found());
426 }
427
428 #[test]
429 fn test_is_conflict_detected_returns_true() {
430 let err = KanbanError::ConflictDetected {
431 path: "test.json".to_string(),
432 source: None,
433 };
434 assert!(err.is_conflict_detected());
435 }
436
437 #[test]
438 fn test_is_wip_limit_exceeded_returns_true() {
439 let id = Uuid::new_v4();
440 let err = KanbanError::Domain(DomainError::wip_limit_exceeded(id, 3));
441 assert!(err.is_wip_limit_exceeded());
442 }
443
444 #[test]
445 fn test_sprint_board_mismatch_display_includes_all_three_ids() {
446 let sprint_id = Uuid::new_v4();
447 let sprint_board = Uuid::new_v4();
448 let card_board = Uuid::new_v4();
449 let err = KanbanError::Domain(DomainError::SprintBoardMismatch {
450 sprint_id,
451 sprint_board,
452 card_board,
453 });
454 let msg = err.to_string();
455 assert!(msg.contains(&sprint_id.to_string()), "msg: {msg}");
456 assert!(msg.contains(&sprint_board.to_string()), "msg: {msg}");
457 assert!(msg.contains(&card_board.to_string()), "msg: {msg}");
458 assert!(msg.contains("belongs to board"), "msg: {msg}");
459 }
460
461 #[test]
462 fn test_is_sprint_board_mismatch_predicate() {
463 let err = KanbanError::Domain(DomainError::SprintBoardMismatch {
464 sprint_id: Uuid::new_v4(),
465 sprint_board: Uuid::new_v4(),
466 card_board: Uuid::new_v4(),
467 });
468 assert!(err.is_sprint_board_mismatch());
469 assert!(!err.is_validation());
470 assert!(!err.is_not_found());
471 }
472
473 #[test]
474 fn test_unsupported_future_version_display_mentions_both_versions() {
475 let err = KanbanError::UnsupportedFutureVersion {
476 file_version: 99,
477 binary_max: 6,
478 };
479 let msg = err.to_string();
480 assert!(msg.contains("99"), "msg should mention file version: {msg}");
481 assert!(msg.contains('6'), "msg should mention binary max: {msg}");
482 }
483
484 #[test]
485 fn test_is_unsupported_future_version_returns_true() {
486 let err = KanbanError::UnsupportedFutureVersion {
487 file_version: 99,
488 binary_max: 6,
489 };
490 assert!(err.is_unsupported_future_version());
491 }
492
493 #[test]
494 fn test_is_unsupported_future_version_returns_false_for_other_error() {
495 let err = KanbanError::not_found("Card", Uuid::new_v4());
496 assert!(!err.is_unsupported_future_version());
497 }
498
499 #[test]
500 fn test_not_found_by_name_display_lists_available() {
501 let err = KanbanError::not_found_by_name(
502 "Column",
503 "done",
504 vec!["TODO".into(), "Doing".into(), "Complete".into()],
505 );
506 let msg = err.to_string();
507 assert!(msg.contains("'done'"), "msg: {msg}");
508 assert!(msg.contains("not found"), "msg: {msg}");
509 assert!(msg.contains("'TODO'"), "msg: {msg}");
510 assert!(msg.contains("'Doing'"), "msg: {msg}");
511 assert!(msg.contains("'Complete'"), "msg: {msg}");
512 }
513
514 #[test]
515 fn test_not_found_by_name_display_with_empty_available_omits_list() {
516 let err = KanbanError::not_found_by_name("Card", "KAN-999", Vec::new());
517 let msg = err.to_string();
518 assert!(msg.contains("'KAN-999' not found"), "msg: {msg}");
519 assert!(!msg.contains("Available:"), "msg: {msg}");
520 }
521
522 #[test]
523 fn test_ambiguous_display_includes_label_and_uuid_per_match() {
524 let a_id = Uuid::new_v4();
527 let b_id = Uuid::new_v4();
528 let err = KanbanError::ambiguous(
529 "Sprint",
530 "13",
531 vec![
532 AmbiguousMatch {
533 label: "on board 'Project A'".into(),
534 id: a_id,
535 },
536 AmbiguousMatch {
537 label: "on board 'Project B'".into(),
538 id: b_id,
539 },
540 ],
541 );
542 let msg = err.to_string();
543 assert!(msg.contains("'13' is ambiguous"), "msg: {msg}");
544 assert!(msg.contains("'Project A'"), "msg: {msg}");
545 assert!(msg.contains("'Project B'"), "msg: {msg}");
546 assert!(
547 msg.contains(&a_id.to_string()),
548 "label-only is not enough: {msg}"
549 );
550 assert!(
551 msg.contains(&b_id.to_string()),
552 "label-only is not enough: {msg}"
553 );
554 }
555
556 #[test]
557 fn test_ambiguous_display_single_match_renders_cleanly() {
558 let id = Uuid::new_v4();
561 let err = KanbanError::ambiguous(
562 "Card",
563 "5",
564 vec![AmbiguousMatch {
565 label: "Some title".into(),
566 id,
567 }],
568 );
569 let msg = err.to_string();
570 assert!(msg.contains("'5' is ambiguous"), "msg: {msg}");
571 assert!(msg.contains("Some title"), "msg: {msg}");
572 assert!(msg.contains(&id.to_string()), "msg: {msg}");
573 }
574
575 #[test]
576 fn test_ambiguous_message_drops_specify_by_uuid_coda() {
577 let err = KanbanError::ambiguous(
580 "Board",
581 "shared",
582 vec![AmbiguousMatch {
583 label: "shared".into(),
584 id: Uuid::new_v4(),
585 }],
586 );
587 let msg = err.to_string();
588 assert!(!msg.contains("Specify by UUID"), "msg: {msg}");
589 }
590
591 #[test]
592 fn test_is_not_found_by_name_predicate() {
593 let err = KanbanError::not_found_by_name("Column", "foo", Vec::new());
594 assert!(err.is_not_found_by_name());
595 assert!(err.is_not_found());
597 }
598
599 #[test]
600 fn test_is_ambiguous_predicate() {
601 let err = KanbanError::ambiguous(
602 "Card",
603 "5",
604 vec![
605 AmbiguousMatch {
606 label: "x".into(),
607 id: Uuid::new_v4(),
608 },
609 AmbiguousMatch {
610 label: "y".into(),
611 id: Uuid::new_v4(),
612 },
613 ],
614 );
615 assert!(err.is_ambiguous());
616 assert!(!err.is_not_found());
617 }
618
619 #[test]
620 fn test_batch_resolution_failed_display_includes_each_input_and_cause() {
621 let err = KanbanError::batch_resolution_failed(
622 "Card",
623 vec![
624 BatchResolutionFailure {
625 raw_input: "KAN-999".into(),
626 cause: BatchResolutionCause::NotFound,
627 },
628 BatchResolutionFailure {
629 raw_input: "KAN-998".into(),
630 cause: BatchResolutionCause::Ambiguous(vec![AmbiguousMatch {
631 label: "'one'".into(),
632 id: Uuid::new_v4(),
633 }]),
634 },
635 ],
636 );
637 let msg = err.to_string();
638 assert!(msg.contains("2 Cards"), "msg: {msg}");
639 assert!(msg.contains("'KAN-999'"), "msg: {msg}");
640 assert!(msg.contains("'KAN-998'"), "msg: {msg}");
641 assert!(msg.contains("not found"), "msg: {msg}");
642 assert!(msg.contains("ambiguous"), "msg: {msg}");
643 }
644
645 #[test]
646 fn test_batch_resolution_failed_display_singularizes_for_one_failure() {
647 let err = KanbanError::batch_resolution_failed(
652 "Card",
653 vec![BatchResolutionFailure {
654 raw_input: "KAN-999".into(),
655 cause: BatchResolutionCause::NotFound,
656 }],
657 );
658 let msg = err.to_string();
659 assert!(msg.contains("1 Card"), "expected '1 Card' singular: {msg}");
660 assert!(
661 !msg.contains("1 Cards") && !msg.contains("card(s)"),
662 "no plural or parenthetical: {msg}"
663 );
664 assert!(msg.contains('('), "still wraps cause in parens: {msg}");
665 }
666
667 #[test]
668 fn test_batch_resolution_failed_display_preserves_entity_capitalization() {
669 let err = KanbanError::batch_resolution_failed(
672 "Card",
673 vec![
674 BatchResolutionFailure {
675 raw_input: "x".into(),
676 cause: BatchResolutionCause::NotFound,
677 },
678 BatchResolutionFailure {
679 raw_input: "y".into(),
680 cause: BatchResolutionCause::NotFound,
681 },
682 ],
683 );
684 let msg = err.to_string();
685 assert!(msg.contains("Cards"), "got: {msg}");
686 assert!(!msg.contains(" cards"), "lowercased entity: {msg}");
687 }
688
689 #[test]
690 fn test_ambiguous_match_display_is_label_then_uuid_in_parens() {
691 let id = Uuid::new_v4();
694 let m = AmbiguousMatch {
695 label: "'Alpha'".into(),
696 id,
697 };
698 let rendered = format!("{}", m);
699 assert_eq!(rendered, format!("'Alpha' ({})", id));
700 }
701
702 #[test]
703 fn test_batch_resolution_cause_display_renders_not_found_and_ambiguous() {
704 let nf = BatchResolutionCause::NotFound;
706 assert_eq!(format!("{}", nf), "not found");
707
708 let id = Uuid::new_v4();
709 let amb = BatchResolutionCause::Ambiguous(vec![
710 AmbiguousMatch {
711 label: "'A'".into(),
712 id,
713 },
714 AmbiguousMatch {
715 label: "'B'".into(),
716 id,
717 },
718 ]);
719 let rendered = format!("{}", amb);
720 assert!(rendered.starts_with("ambiguous: "), "got: {rendered}");
721 assert!(rendered.contains("'A'"), "got: {rendered}");
722 assert!(rendered.contains("'B'"), "got: {rendered}");
723 assert!(
724 rendered.matches(&id.to_string()).count() == 2,
725 "got: {rendered}"
726 );
727 }
728
729 #[test]
730 fn test_is_batch_resolution_failed_predicate() {
731 let err = KanbanError::batch_resolution_failed("Card", Vec::new());
732 assert!(err.is_batch_resolution_failed());
733 assert!(!err.is_not_found(), "not the same as a single not-found");
734 }
735
736 #[test]
737 fn test_is_not_found_true_for_uuid_variant_too() {
738 let err = KanbanError::not_found("Card", Uuid::new_v4());
739 assert!(err.is_not_found(), "umbrella predicate covers Uuid variant");
740 assert!(!err.is_not_found_by_name());
741 }
742
743 #[test]
744 fn test_not_found_display_includes_entity_and_id() {
745 let id = Uuid::new_v4();
746 let err = KanbanError::not_found("Card", id);
747 let msg = err.to_string();
748 assert!(msg.contains("Card"));
749 assert!(msg.contains(&id.to_string()));
750 }
751
752 #[test]
753 fn test_from_dependency_error_converts_to_kanban_domain() {
754 let dep_err = DependencyError::CycleDetected;
755 let kanban_err = KanbanError::from(dep_err);
756 assert!(matches!(
757 kanban_err,
758 KanbanError::Domain(DomainError::Dependency(DependencyError::CycleDetected))
759 ));
760 }
761
762 #[test]
763 fn test_from_core_error_validation_converts_to_kanban_validation() {
764 let core_err = kanban_core::CoreError::Validation("bad".to_string());
765 let kanban_err = KanbanError::from(core_err);
766 assert!(kanban_err.is_validation());
767 }
768
769 #[test]
770 fn test_from_core_error_config_converts_to_internal() {
771 let core_err = kanban_core::CoreError::Config("cfg error".to_string());
772 let kanban_err = KanbanError::from(core_err);
773 assert!(matches!(kanban_err, KanbanError::Internal(_)));
774 }
775}