Skip to main content

ito_domain/
errors.rs

1//! Domain-layer error types.
2
3use std::io;
4
5use thiserror::Error;
6
7/// Result alias for domain-layer operations.
8pub type DomainResult<T> = Result<T, DomainError>;
9
10/// Error type used by domain ports and domain utilities.
11#[derive(Debug, Error)]
12pub enum DomainError {
13    /// Filesystem or other IO failure.
14    #[error("I/O failure while {context}: {source}")]
15    Io {
16        /// Short operation context.
17        context: &'static str,
18        /// Source error.
19        #[source]
20        source: io::Error,
21    },
22
23    /// Requested entity was not found.
24    #[error("{entity} not found: {id}")]
25    NotFound {
26        /// Entity kind.
27        entity: &'static str,
28        /// Requested identifier.
29        id: String,
30    },
31
32    /// Target was ambiguous and matched multiple entities.
33    #[error("Ambiguous {entity} target '{input}'. Matches: {matches}")]
34    AmbiguousTarget {
35        /// Entity kind.
36        entity: &'static str,
37        /// User-provided target.
38        input: String,
39        /// Comma-separated matching candidates.
40        matches: String,
41    },
42}
43
44impl DomainError {
45    /// Build an IO-flavored domain error with a static context string.
46    pub fn io(context: &'static str, source: io::Error) -> Self {
47        Self::Io { context, source }
48    }
49
50    /// Build a not-found error for an entity.
51    pub fn not_found(entity: &'static str, id: impl Into<String>) -> Self {
52        Self::NotFound {
53            entity,
54            id: id.into(),
55        }
56    }
57
58    /// Build an ambiguity error for an entity target.
59    pub fn ambiguous_target(entity: &'static str, input: &str, matches: &[String]) -> Self {
60        Self::AmbiguousTarget {
61            entity,
62            input: input.to_string(),
63            matches: matches.join(", "),
64        }
65    }
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    #[test]
73    fn io_constructor_preserves_context_and_source() {
74        let source = io::Error::new(io::ErrorKind::PermissionDenied, "no access");
75        let error = DomainError::io("reading tasks", source);
76
77        match error {
78            DomainError::Io { context, source } => {
79                assert_eq!(context, "reading tasks");
80                assert_eq!(source.kind(), io::ErrorKind::PermissionDenied);
81                assert_eq!(source.to_string(), "no access");
82            }
83            other => panic!("expected io variant, got {other:?}"),
84        }
85    }
86
87    #[test]
88    fn not_found_constructor_formats_display_message() {
89        let error = DomainError::not_found("module", "123_core");
90
91        match &error {
92            DomainError::NotFound { entity, id } => {
93                assert_eq!(*entity, "module");
94                assert_eq!(id, "123_core");
95            }
96            other => panic!("expected not found variant, got {other:?}"),
97        }
98
99        assert_eq!(error.to_string(), "module not found: 123_core");
100    }
101
102    #[test]
103    fn ambiguous_target_joins_candidates_in_display_message() {
104        let matches = vec!["001-01_alpha".to_string(), "001-02_alpha-fix".to_string()];
105        let error = DomainError::ambiguous_target("change", "alpha", &matches);
106
107        match &error {
108            DomainError::AmbiguousTarget {
109                entity,
110                input,
111                matches,
112            } => {
113                assert_eq!(*entity, "change");
114                assert_eq!(input, "alpha");
115                assert_eq!(matches, "001-01_alpha, 001-02_alpha-fix");
116            }
117            other => panic!("expected ambiguous target variant, got {other:?}"),
118        }
119
120        assert_eq!(
121            error.to_string(),
122            "Ambiguous change target 'alpha'. Matches: 001-01_alpha, 001-02_alpha-fix"
123        );
124    }
125}