holoconf_core/
error.rs

1//! Error types for holoconf
2//!
3//! Error handling follows ADR-008: structured errors with context,
4//! path information, and actionable help messages.
5
6use std::fmt;
7
8/// Result type alias for holoconf operations
9pub type Result<T> = std::result::Result<T, Error>;
10
11/// Main error type for holoconf operations
12#[derive(Debug, Clone)]
13pub struct Error {
14    /// The kind of error that occurred
15    pub kind: ErrorKind,
16    /// Path in the config where the error occurred (e.g., "database.port")
17    pub path: Option<String>,
18    /// Source location (file, line) if available
19    pub source_location: Option<SourceLocation>,
20    /// Actionable help message
21    pub help: Option<String>,
22    /// Underlying cause (as string for Clone compatibility)
23    pub cause: Option<String>,
24}
25
26/// Location in a source file
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct SourceLocation {
29    pub file: String,
30    pub line: Option<usize>,
31    pub column: Option<usize>,
32}
33
34/// Categories of errors that can occur
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum ErrorKind {
37    /// Error parsing YAML/JSON
38    Parse,
39    /// Error during value resolution
40    Resolver(ResolverErrorKind),
41    /// Error during schema validation
42    Validation,
43    /// Error accessing a path that doesn't exist
44    PathNotFound,
45    /// Circular reference detected
46    CircularReference,
47    /// Type coercion failed
48    TypeCoercion,
49    /// I/O error (file not found, etc.)
50    Io,
51    /// Internal error (bug in holoconf)
52    Internal,
53}
54
55/// Specific resolver error categories
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum ResolverErrorKind {
58    /// Environment variable not found
59    EnvNotFound { var_name: String },
60    /// File not found
61    FileNotFound { path: String },
62    /// HTTP request failed
63    HttpError { url: String, status: Option<u16> },
64    /// HTTP resolver is disabled
65    HttpDisabled,
66    /// URL not in allowlist
67    HttpNotAllowed { url: String },
68    /// Referenced config path not found
69    RefNotFound { ref_path: String },
70    /// Unknown resolver
71    UnknownResolver { name: String },
72    /// Resolver returned an error
73    Custom { resolver: String, message: String },
74}
75
76impl Error {
77    /// Create a new parse error
78    pub fn parse(message: impl Into<String>) -> Self {
79        Self {
80            kind: ErrorKind::Parse,
81            path: None,
82            source_location: None,
83            help: None,
84            cause: Some(message.into()),
85        }
86    }
87
88    /// Create a path not found error
89    pub fn path_not_found(path: impl Into<String>) -> Self {
90        let path_str = path.into();
91        Self {
92            kind: ErrorKind::PathNotFound,
93            path: Some(path_str.clone()),
94            source_location: None,
95            help: Some(format!(
96                "Check that '{}' exists in the configuration",
97                path_str
98            )),
99            cause: None,
100        }
101    }
102
103    /// Create a circular reference error
104    pub fn circular_reference(path: impl Into<String>, chain: Vec<String>) -> Self {
105        let chain_str = chain.join(" → ");
106        Self {
107            kind: ErrorKind::CircularReference,
108            path: Some(path.into()),
109            source_location: None,
110            help: Some("Break the circular dependency by removing one of the references".into()),
111            cause: Some(format!("Chain: {}", chain_str)),
112        }
113    }
114
115    /// Create an env var not found error
116    pub fn env_not_found(var_name: impl Into<String>, config_path: Option<String>) -> Self {
117        let var = var_name.into();
118        Self {
119            kind: ErrorKind::Resolver(ResolverErrorKind::EnvNotFound {
120                var_name: var.clone(),
121            }),
122            path: config_path,
123            source_location: None,
124            help: Some(format!(
125                "Set the {} environment variable or provide a default: ${{env:{},default}}",
126                var, var
127            )),
128            cause: None,
129        }
130    }
131
132    /// Create a reference not found error
133    pub fn ref_not_found(ref_path: impl Into<String>, config_path: Option<String>) -> Self {
134        let ref_p = ref_path.into();
135        Self {
136            kind: ErrorKind::Resolver(ResolverErrorKind::RefNotFound {
137                ref_path: ref_p.clone(),
138            }),
139            path: config_path,
140            source_location: None,
141            help: Some(format!(
142                "Check that '{}' exists in the configuration",
143                ref_p
144            )),
145            cause: None,
146        }
147    }
148
149    /// Create a file not found error
150    pub fn file_not_found(file_path: impl Into<String>, config_path: Option<String>) -> Self {
151        let fp = file_path.into();
152        Self {
153            kind: ErrorKind::Resolver(ResolverErrorKind::FileNotFound { path: fp.clone() }),
154            path: config_path,
155            source_location: None,
156            help: Some("Check that the file exists relative to the config file".into()),
157            cause: None,
158        }
159    }
160
161    /// Create an unknown resolver error
162    pub fn unknown_resolver(name: impl Into<String>, config_path: Option<String>) -> Self {
163        let n = name.into();
164        Self {
165            kind: ErrorKind::Resolver(ResolverErrorKind::UnknownResolver { name: n.clone() }),
166            path: config_path,
167            source_location: None,
168            help: Some(format!("Register the '{}' resolver or check for typos", n)),
169            cause: None,
170        }
171    }
172
173    /// Create a type coercion error
174    pub fn type_coercion(
175        path: impl Into<String>,
176        expected: impl Into<String>,
177        got: impl Into<String>,
178    ) -> Self {
179        Self {
180            kind: ErrorKind::TypeCoercion,
181            path: Some(path.into()),
182            source_location: None,
183            help: Some(format!(
184                "Ensure the value can be converted to {}",
185                expected.into()
186            )),
187            cause: Some(format!("Got: {}", got.into())),
188        }
189    }
190
191    /// Create a validation error
192    pub fn validation(path: impl Into<String>, message: impl Into<String>) -> Self {
193        let p = path.into();
194        Self {
195            kind: ErrorKind::Validation,
196            path: if p.is_empty() || p == "<root>" {
197                None
198            } else {
199                Some(p)
200            },
201            source_location: None,
202            help: Some("Fix the value to match the schema requirements".into()),
203            cause: Some(message.into()),
204        }
205    }
206
207    /// Add path context to the error
208    pub fn with_path(mut self, path: impl Into<String>) -> Self {
209        self.path = Some(path.into());
210        self
211    }
212
213    /// Add source location to the error
214    pub fn with_source_location(mut self, loc: SourceLocation) -> Self {
215        self.source_location = Some(loc);
216        self
217    }
218
219    /// Add help message to the error
220    pub fn with_help(mut self, help: impl Into<String>) -> Self {
221        self.help = Some(help.into());
222        self
223    }
224}
225
226impl fmt::Display for Error {
227    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
228        // Main error message
229        match &self.kind {
230            ErrorKind::Parse => write!(f, "Parse error")?,
231            ErrorKind::Resolver(r) => match r {
232                ResolverErrorKind::EnvNotFound { var_name } => {
233                    write!(f, "Environment variable not found: {}", var_name)?
234                }
235                ResolverErrorKind::FileNotFound { path } => write!(f, "File not found: {}", path)?,
236                ResolverErrorKind::HttpError { url, status } => {
237                    write!(f, "HTTP request failed: {}", url)?;
238                    if let Some(s) = status {
239                        write!(f, " (status {})", s)?;
240                    }
241                }
242                ResolverErrorKind::HttpDisabled => write!(f, "HTTP resolver is disabled")?,
243                ResolverErrorKind::HttpNotAllowed { url } => {
244                    write!(f, "URL not in allowlist: {}", url)?
245                }
246                ResolverErrorKind::RefNotFound { ref_path } => {
247                    write!(f, "Referenced path not found: {}", ref_path)?
248                }
249                ResolverErrorKind::UnknownResolver { name } => {
250                    write!(f, "Unknown resolver: {}", name)?
251                }
252                ResolverErrorKind::Custom { resolver, message } => {
253                    write!(f, "Resolver '{}' error: {}", resolver, message)?
254                }
255            },
256            ErrorKind::Validation => write!(f, "Validation error")?,
257            ErrorKind::PathNotFound => write!(f, "Path not found")?,
258            ErrorKind::CircularReference => write!(f, "Circular reference detected")?,
259            ErrorKind::TypeCoercion => write!(f, "Type coercion failed")?,
260            ErrorKind::Io => write!(f, "I/O error")?,
261            ErrorKind::Internal => write!(f, "Internal error")?,
262        }
263
264        // Path context
265        if let Some(path) = &self.path {
266            write!(f, "\n  Path: {}", path)?;
267        }
268
269        // Source location
270        if let Some(loc) = &self.source_location {
271            write!(f, "\n  File: {}", loc.file)?;
272            if let Some(line) = loc.line {
273                write!(f, ":{}", line)?;
274            }
275        }
276
277        // Cause
278        if let Some(cause) = &self.cause {
279            write!(f, "\n  {}", cause)?;
280        }
281
282        // Help
283        if let Some(help) = &self.help {
284            write!(f, "\n  Help: {}", help)?;
285        }
286
287        Ok(())
288    }
289}
290
291impl std::error::Error for Error {}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn test_env_not_found_error_display() {
299        let err = Error::env_not_found("MY_VAR", Some("database.password".into()));
300        let display = format!("{}", err);
301
302        assert!(display.contains("Environment variable not found: MY_VAR"));
303        assert!(display.contains("Path: database.password"));
304        assert!(display.contains("Help:"));
305        assert!(display.contains("${env:MY_VAR,default}"));
306    }
307
308    #[test]
309    fn test_circular_reference_error_display() {
310        let err = Error::circular_reference(
311            "config.a",
312            vec!["a".into(), "b".into(), "c".into(), "a".into()],
313        );
314        let display = format!("{}", err);
315
316        assert!(display.contains("Circular reference detected"));
317        assert!(display.contains("a → b → c → a"));
318    }
319
320    #[test]
321    fn test_path_not_found_error() {
322        let err = Error::path_not_found("database.host");
323
324        assert_eq!(err.kind, ErrorKind::PathNotFound);
325        assert_eq!(err.path, Some("database.host".into()));
326    }
327}