Skip to main content

kanban_domain/
error.rs

1use std::fmt;
2
3use thiserror::Error;
4use uuid::Uuid;
5
6/// One element of an `Ambiguous` error's match list. Carries both a
7/// human-readable label (what makes this match distinguishable from the
8/// others) and the entity's UUID (always copy-pasteable). Display always
9/// renders as `{label} ({uuid})` so users can disambiguate by either.
10#[derive(Debug, Clone)]
11pub struct AmbiguousMatch {
12    /// Human-readable label that distinguishes this match. Examples:
13    ///   - board name (when two boards share a name)
14    ///   - `"on board 'X'"` (when a column name appears on multiple boards)
15    ///   - `"#15 'yarara-release' on board 'Project A'"` (sprint global match)
16    ///   - card title
17    pub label: String,
18    /// The matched entity's UUID.
19    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/// One element of a `BatchResolutionFailed` error. Carries the raw input
29/// the caller passed and the typed reason it couldn't be resolved.
30#[derive(Debug, Clone)]
31pub struct BatchResolutionFailure {
32    /// The string the caller passed for this slot (UUID, identifier, name, etc.).
33    pub raw_input: String,
34    /// Why this input couldn't be resolved.
35    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/// Why a single input in a batch resolver call failed.
45#[derive(Debug, Clone)]
46pub enum BatchResolutionCause {
47    /// No entity matched the input.
48    NotFound,
49    /// More than one entity matched. Carries the same match data an
50    /// `Ambiguous` single-resolver error would.
51    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    /// `entity` is rendered as a sentence-leading noun (e.g. `"Card"`,
87    /// `"Sprint"`). All call sites of `KanbanError::not_found(entity, id)`
88    /// must pass a capitalized noun so the rendered message reads
89    /// `"Card <uuid> not found"`, matching the sibling `NotFoundByName`
90    /// convention. See KAN-659 for the normalization history.
91    #[error("{entity} {id} not found")]
92    NotFound { entity: &'static str, id: Uuid },
93
94    /// Returned when a name- or identifier-based lookup misses. The `available`
95    /// vector is appended to the message so users see what they could have typed.
96    /// `entity` follows the same capitalized-noun convention as
97    /// [`NotFound`](Self::NotFound).
98    #[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    /// Returned when a name- or identifier-based lookup matches more than one
106    /// entity. Each match carries both a human-readable label and a UUID so
107    /// users can disambiguate by either.
108    #[error("{}", DomainError::fmt_ambiguous(entity, name, matches))]
109    Ambiguous {
110        entity: &'static str,
111        name: String,
112        matches: Vec<AmbiguousMatch>,
113    },
114
115    /// Returned by batch resolvers (`resolve_card_ids`, future siblings) when
116    /// one or more inputs in the batch couldn't be resolved. Carries per-input
117    /// typed causes so callers can introspect.
118    #[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    // ----- Display formatters for the name/identifier resolver variants -----
145
146    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
222/// Return `noun` (when count is 1) or `noun + "s"` (otherwise).
223/// Trivial English helper used by error message formatters.
224fn 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    /// True for both `NotFound` (by UUID) and `NotFoundByName`.
275    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        // KAN-400 polish: every match carries both a human label and a UUID
525        // so users can disambiguate by either.
526        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        // Degenerate case; can happen if a different resolver path produces
559        // a single-match Ambiguous (shouldn't, but be defensive).
560        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        // The old wording "Specify by UUID" was redundant once the UUID is
578        // already in the message. New message doesn't repeat it.
579        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        // is_not_found is the umbrella predicate covering both shapes.
596        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        // KAN-400 review-3 fix: "1 card(s)" was grammatically awkward. Now
648        // the noun agrees in number with the count, and entity casing matches
649        // sibling error variants (NotFoundByName / Ambiguous both keep
650        // "Card" capitalized).
651        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        // Sibling variants render "Card '...' not found"; the batch variant
670        // must match. No to_lowercase.
671        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        // Standalone Display impl on AmbiguousMatch so callers can render
692        // a match without re-implementing the format.
693        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        // Standalone Display impl on BatchResolutionCause for symmetry.
705        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}