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    /// Resource not found (triggers default handling if default is provided)
59    /// This is used when the resolver cannot find the requested resource
60    /// (e.g., env var not set, file not found, SSM parameter missing)
61    NotFound { resource: String },
62    /// Environment variable not found
63    EnvNotFound { var_name: String },
64    /// File not found
65    FileNotFound { path: String },
66    /// HTTP request failed
67    HttpError { url: String, status: Option<u16> },
68    /// HTTP resolver is disabled
69    HttpDisabled,
70    /// URL not in allowlist
71    HttpNotAllowed { url: String },
72    /// TLS configuration error
73    TlsConfigError { message: String },
74    /// Proxy configuration error
75    ProxyConfigError { message: String },
76    /// PEM file loading error
77    PemLoadError { path: String, message: String },
78    /// P12/PFX file loading error
79    P12LoadError { path: String, message: String },
80    /// Key decryption error
81    KeyDecryptionError { message: String },
82    /// Referenced config path not found
83    RefNotFound { ref_path: String },
84    /// Unknown resolver
85    UnknownResolver { name: String },
86    /// Resolver returned an error
87    Custom { resolver: String, message: String },
88    /// Resolver already registered
89    AlreadyRegistered { name: String },
90}
91
92impl Error {
93    /// Create a new parse error
94    pub fn parse(message: impl Into<String>) -> Self {
95        Self {
96            kind: ErrorKind::Parse,
97            path: None,
98            source_location: None,
99            help: None,
100            cause: Some(message.into()),
101        }
102    }
103
104    /// Create a path not found error
105    pub fn path_not_found(path: impl Into<String>) -> Self {
106        let path_str = path.into();
107        Self {
108            kind: ErrorKind::PathNotFound,
109            path: Some(path_str.clone()),
110            source_location: None,
111            help: Some(format!(
112                "Check that '{}' exists in the configuration",
113                path_str
114            )),
115            cause: None,
116        }
117    }
118
119    /// Create a circular reference error
120    pub fn circular_reference(path: impl Into<String>, chain: Vec<String>) -> Self {
121        let chain_str = chain.join(" → ");
122        Self {
123            kind: ErrorKind::CircularReference,
124            path: Some(path.into()),
125            source_location: None,
126            help: Some("Break the circular dependency by removing one of the references".into()),
127            cause: Some(format!("Chain: {}", chain_str)),
128        }
129    }
130
131    /// Create a not found error (triggers default handling at framework level)
132    pub fn not_found(resource: impl Into<String>, config_path: Option<String>) -> Self {
133        let res = resource.into();
134        Self {
135            kind: ErrorKind::Resolver(ResolverErrorKind::NotFound { resource: res }),
136            path: config_path,
137            source_location: None,
138            help: None,
139            cause: None,
140        }
141    }
142
143    /// Create an env var not found error
144    pub fn env_not_found(var_name: impl Into<String>, config_path: Option<String>) -> Self {
145        let var = var_name.into();
146        Self {
147            kind: ErrorKind::Resolver(ResolverErrorKind::EnvNotFound {
148                var_name: var.clone(),
149            }),
150            path: config_path,
151            source_location: None,
152            help: Some(format!(
153                "Set the {} environment variable or provide a default: ${{env:{},default=value}}",
154                var, var
155            )),
156            cause: None,
157        }
158    }
159
160    /// Create a reference not found error
161    pub fn ref_not_found(ref_path: impl Into<String>, config_path: Option<String>) -> Self {
162        let ref_p = ref_path.into();
163        Self {
164            kind: ErrorKind::Resolver(ResolverErrorKind::RefNotFound {
165                ref_path: ref_p.clone(),
166            }),
167            path: config_path,
168            source_location: None,
169            help: Some(format!(
170                "Check that '{}' exists in the configuration",
171                ref_p
172            )),
173            cause: None,
174        }
175    }
176
177    /// Create a file not found error
178    pub fn file_not_found(file_path: impl Into<String>, config_path: Option<String>) -> Self {
179        let fp = file_path.into();
180        Self {
181            kind: ErrorKind::Resolver(ResolverErrorKind::FileNotFound { path: fp.clone() }),
182            path: config_path,
183            source_location: None,
184            help: Some("Check that the file exists relative to the config file".into()),
185            cause: None,
186        }
187    }
188
189    /// Create an unknown resolver error
190    pub fn unknown_resolver(name: impl Into<String>, config_path: Option<String>) -> Self {
191        let n = name.into();
192        Self {
193            kind: ErrorKind::Resolver(ResolverErrorKind::UnknownResolver { name: n.clone() }),
194            path: config_path,
195            source_location: None,
196            help: Some(format!("Register the '{}' resolver or check for typos", n)),
197            cause: None,
198        }
199    }
200
201    /// Create a resolver already registered error
202    pub fn resolver_already_registered(name: impl Into<String>) -> Self {
203        let n = name.into();
204        Self {
205            kind: ErrorKind::Resolver(ResolverErrorKind::AlreadyRegistered { name: n.clone() }),
206            path: None,
207            source_location: None,
208            help: Some(format!(
209                "Use register_with_force(..., force=true) to override the '{}' resolver",
210                n
211            )),
212            cause: None,
213        }
214    }
215
216    /// Create a type coercion error
217    pub fn type_coercion(
218        path: impl Into<String>,
219        expected: impl Into<String>,
220        got: impl Into<String>,
221    ) -> Self {
222        Self {
223            kind: ErrorKind::TypeCoercion,
224            path: Some(path.into()),
225            source_location: None,
226            help: Some(format!(
227                "Ensure the value can be converted to {}",
228                expected.into()
229            )),
230            cause: Some(format!("Got: {}", got.into())),
231        }
232    }
233
234    /// Create a validation error
235    pub fn validation(path: impl Into<String>, message: impl Into<String>) -> Self {
236        let p = path.into();
237        Self {
238            kind: ErrorKind::Validation,
239            path: if p.is_empty() || p == "<root>" {
240                None
241            } else {
242                Some(p)
243            },
244            source_location: None,
245            help: Some("Fix the value to match the schema requirements".into()),
246            cause: Some(message.into()),
247        }
248    }
249
250    /// Add path context to the error
251    pub fn with_path(mut self, path: impl Into<String>) -> Self {
252        self.path = Some(path.into());
253        self
254    }
255
256    /// Add source location to the error
257    pub fn with_source_location(mut self, loc: SourceLocation) -> Self {
258        self.source_location = Some(loc);
259        self
260    }
261
262    /// Add help message to the error
263    pub fn with_help(mut self, help: impl Into<String>) -> Self {
264        self.help = Some(help.into());
265        self
266    }
267
268    /// Create a custom resolver error
269    pub fn resolver_custom(resolver: impl Into<String>, message: impl Into<String>) -> Self {
270        let resolver_name = resolver.into();
271        Self {
272            kind: ErrorKind::Resolver(ResolverErrorKind::Custom {
273                resolver: resolver_name.clone(),
274                message: message.into(),
275            }),
276            path: None,
277            source_location: None,
278            help: Some(format!(
279                "Check the '{}' resolver implementation",
280                resolver_name
281            )),
282            cause: None,
283        }
284    }
285
286    /// Create an HTTP request failed error
287    pub fn http_request_failed(
288        url: impl Into<String>,
289        message: impl Into<String>,
290        config_path: Option<String>,
291    ) -> Self {
292        let url_str = url.into();
293        Self {
294            kind: ErrorKind::Resolver(ResolverErrorKind::HttpError {
295                url: url_str.clone(),
296                status: None,
297            }),
298            path: config_path,
299            source_location: None,
300            help: Some(format!(
301                "Check that the URL '{}' is accessible and returns valid content",
302                url_str
303            )),
304            cause: Some(message.into()),
305        }
306    }
307
308    /// Create an HTTP not in allowlist error
309    pub fn http_not_in_allowlist(
310        url: impl Into<String>,
311        allowlist: &[String],
312        config_path: Option<String>,
313    ) -> Self {
314        let url_str = url.into();
315        let allowlist_str = if allowlist.is_empty() {
316            "(empty)".to_string()
317        } else {
318            allowlist.join(", ")
319        };
320        Self {
321            kind: ErrorKind::Resolver(ResolverErrorKind::HttpNotAllowed {
322                url: url_str.clone(),
323            }),
324            path: config_path,
325            source_location: None,
326            help: Some(format!(
327                "Add '{}' to the http_allowlist, or use a pattern that matches it.\nCurrent allowlist: {}",
328                url_str, allowlist_str
329            )),
330            cause: None,
331        }
332    }
333
334    /// Create a TLS configuration error
335    pub fn tls_config_error(message: impl Into<String>) -> Self {
336        Self {
337            kind: ErrorKind::Resolver(ResolverErrorKind::TlsConfigError {
338                message: message.into(),
339            }),
340            path: None,
341            source_location: None,
342            help: Some("Check your TLS configuration (CA bundles, client certificates)".into()),
343            cause: None,
344        }
345    }
346
347    /// Create a proxy configuration error
348    pub fn proxy_config_error(message: impl Into<String>) -> Self {
349        Self {
350            kind: ErrorKind::Resolver(ResolverErrorKind::ProxyConfigError {
351                message: message.into(),
352            }),
353            path: None,
354            source_location: None,
355            help: Some(
356                "Check your proxy URL format (e.g., http://proxy:8080 or socks5://proxy:1080)"
357                    .into(),
358            ),
359            cause: None,
360        }
361    }
362
363    /// Create a PEM file loading error
364    pub fn pem_load_error(path: impl Into<String>, message: impl Into<String>) -> Self {
365        let path_str = path.into();
366        Self {
367            kind: ErrorKind::Resolver(ResolverErrorKind::PemLoadError {
368                path: path_str.clone(),
369                message: message.into(),
370            }),
371            path: None,
372            source_location: None,
373            help: Some(format!(
374                "Ensure '{}' exists and contains valid PEM-encoded data",
375                path_str
376            )),
377            cause: None,
378        }
379    }
380
381    /// Create a P12/PFX file loading error
382    pub fn p12_load_error(path: impl Into<String>, message: impl Into<String>) -> Self {
383        let path_str = path.into();
384        Self {
385            kind: ErrorKind::Resolver(ResolverErrorKind::P12LoadError {
386                path: path_str.clone(),
387                message: message.into(),
388            }),
389            path: None,
390            source_location: None,
391            help: Some(format!(
392                "Ensure '{}' exists and contains a valid PKCS#12/PFX bundle with the correct password",
393                path_str
394            )),
395            cause: None,
396        }
397    }
398
399    /// Create a key decryption error
400    pub fn key_decryption_error(message: impl Into<String>) -> Self {
401        Self {
402            kind: ErrorKind::Resolver(ResolverErrorKind::KeyDecryptionError {
403                message: message.into(),
404            }),
405            path: None,
406            source_location: None,
407            help: Some("Check that the password is correct for the encrypted private key".into()),
408            cause: None,
409        }
410    }
411
412    /// Create an internal error (bug in holoconf)
413    pub fn internal(message: impl Into<String>) -> Self {
414        Self {
415            kind: ErrorKind::Internal,
416            path: None,
417            source_location: None,
418            help: Some("This is likely a bug in holoconf. Please report it.".into()),
419            cause: Some(message.into()),
420        }
421    }
422}
423
424impl fmt::Display for Error {
425    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
426        // Main error message
427        match &self.kind {
428            ErrorKind::Parse => write!(f, "Parse error")?,
429            ErrorKind::Resolver(r) => match r {
430                ResolverErrorKind::NotFound { resource } => {
431                    write!(f, "Resource not found: {}", resource)?
432                }
433                ResolverErrorKind::EnvNotFound { var_name } => {
434                    write!(f, "Environment variable not found: {}", var_name)?
435                }
436                ResolverErrorKind::FileNotFound { path } => write!(f, "File not found: {}", path)?,
437                ResolverErrorKind::HttpError { url, status } => {
438                    write!(f, "HTTP request failed: {}", url)?;
439                    if let Some(s) = status {
440                        write!(f, " (status {})", s)?;
441                    }
442                }
443                ResolverErrorKind::HttpDisabled => write!(f, "HTTP resolver is disabled")?,
444                ResolverErrorKind::HttpNotAllowed { url } => {
445                    write!(f, "URL not in allowlist: {}", url)?
446                }
447                ResolverErrorKind::RefNotFound { ref_path } => {
448                    write!(f, "Referenced path not found: {}", ref_path)?
449                }
450                ResolverErrorKind::UnknownResolver { name } => {
451                    write!(f, "Unknown resolver: {}", name)?
452                }
453                ResolverErrorKind::Custom { resolver, message } => {
454                    write!(f, "Resolver '{}' error: {}", resolver, message)?
455                }
456                ResolverErrorKind::AlreadyRegistered { name } => {
457                    write!(f, "Resolver '{}' is already registered", name)?
458                }
459                ResolverErrorKind::TlsConfigError { message } => {
460                    write!(f, "TLS configuration error: {}", message)?
461                }
462                ResolverErrorKind::ProxyConfigError { message } => {
463                    write!(f, "Proxy configuration error: {}", message)?
464                }
465                ResolverErrorKind::PemLoadError { path, message } => {
466                    write!(f, "Failed to load PEM file '{}': {}", path, message)?
467                }
468                ResolverErrorKind::P12LoadError { path, message } => {
469                    write!(f, "Failed to load P12/PFX file '{}': {}", path, message)?
470                }
471                ResolverErrorKind::KeyDecryptionError { message } => {
472                    write!(f, "Failed to decrypt private key: {}", message)?
473                }
474            },
475            ErrorKind::Validation => write!(f, "Validation error")?,
476            ErrorKind::PathNotFound => write!(f, "Path not found")?,
477            ErrorKind::CircularReference => write!(f, "Circular reference detected")?,
478            ErrorKind::TypeCoercion => write!(f, "Type coercion failed")?,
479            ErrorKind::Io => write!(f, "I/O error")?,
480            ErrorKind::Internal => write!(f, "Internal error")?,
481        }
482
483        // Path context
484        if let Some(path) = &self.path {
485            write!(f, "\n  Path: {}", path)?;
486        }
487
488        // Source location
489        if let Some(loc) = &self.source_location {
490            write!(f, "\n  File: {}", loc.file)?;
491            if let Some(line) = loc.line {
492                write!(f, ":{}", line)?;
493            }
494        }
495
496        // Cause
497        if let Some(cause) = &self.cause {
498            write!(f, "\n  {}", cause)?;
499        }
500
501        // Help
502        if let Some(help) = &self.help {
503            write!(f, "\n  Help: {}", help)?;
504        }
505
506        Ok(())
507    }
508}
509
510impl std::error::Error for Error {}
511
512#[cfg(test)]
513mod tests {
514    use super::*;
515
516    #[test]
517    fn test_env_not_found_error_display() {
518        let err = Error::env_not_found("MY_VAR", Some("database.password".into()));
519        let display = format!("{}", err);
520
521        assert!(display.contains("Environment variable not found: MY_VAR"));
522        assert!(display.contains("Path: database.password"));
523        assert!(display.contains("Help:"));
524        assert!(display.contains("${env:MY_VAR,default=value}"));
525    }
526
527    #[test]
528    fn test_circular_reference_error_display() {
529        let err = Error::circular_reference(
530            "config.a",
531            vec!["a".into(), "b".into(), "c".into(), "a".into()],
532        );
533        let display = format!("{}", err);
534
535        assert!(display.contains("Circular reference detected"));
536        assert!(display.contains("a → b → c → a"));
537    }
538
539    #[test]
540    fn test_path_not_found_error() {
541        let err = Error::path_not_found("database.host");
542
543        assert_eq!(err.kind, ErrorKind::PathNotFound);
544        assert_eq!(err.path, Some("database.host".into()));
545    }
546
547    #[test]
548    fn test_not_found_error() {
549        let err = Error::not_found("my-resource", Some("config.key".into()));
550        let display = format!("{}", err);
551
552        assert!(display.contains("Resource not found: my-resource"));
553        assert!(display.contains("Path: config.key"));
554        assert!(matches!(
555            err.kind,
556            ErrorKind::Resolver(ResolverErrorKind::NotFound { .. })
557        ));
558    }
559
560    #[test]
561    fn test_ref_not_found_error() {
562        let err = Error::ref_not_found("database.missing", Some("app.db".into()));
563        let display = format!("{}", err);
564
565        assert!(display.contains("Referenced path not found: database.missing"));
566        assert!(display.contains("Path: app.db"));
567        assert!(display.contains("Help:"));
568    }
569
570    #[test]
571    fn test_file_not_found_error() {
572        let err = Error::file_not_found("/path/to/missing.yaml", Some("config.file".into()));
573        let display = format!("{}", err);
574
575        assert!(display.contains("File not found: /path/to/missing.yaml"));
576        assert!(display.contains("Path: config.file"));
577    }
578
579    #[test]
580    fn test_unknown_resolver_error() {
581        let err = Error::unknown_resolver("unknown", Some("config.value".into()));
582        let display = format!("{}", err);
583
584        assert!(display.contains("Unknown resolver: unknown"));
585        assert!(display.contains("Help:"));
586        assert!(display.contains("Register the 'unknown' resolver"));
587    }
588
589    #[test]
590    fn test_resolver_custom_error() {
591        let err = Error::resolver_custom("myresolver", "Something went wrong");
592        let display = format!("{}", err);
593
594        assert!(display.contains("Resolver 'myresolver' error: Something went wrong"));
595        assert!(display.contains("Help:"));
596    }
597
598    #[test]
599    fn test_internal_error() {
600        let err = Error::internal("Unexpected state");
601        let display = format!("{}", err);
602
603        assert!(display.contains("Internal error"));
604        // The message goes into the cause field
605        assert!(display.contains("Unexpected state"));
606    }
607
608    #[test]
609    fn test_with_source_location() {
610        let err = Error::parse("syntax error").with_source_location(SourceLocation {
611            file: "config.yaml".into(),
612            line: Some(42),
613            column: None,
614        });
615        let display = format!("{}", err);
616
617        assert!(display.contains("config.yaml:42"));
618    }
619
620    #[test]
621    fn test_with_help() {
622        let err = Error::parse("bad input").with_help("Try fixing the syntax");
623        let display = format!("{}", err);
624
625        assert!(display.contains("Help: Try fixing the syntax"));
626    }
627
628    #[test]
629    fn test_type_coercion_error() {
630        let err = Error::type_coercion("server.port", "integer", "string");
631        let display = format!("{}", err);
632
633        assert!(display.contains("Type coercion failed"));
634        assert!(display.contains("Path: server.port"));
635        assert!(display.contains("Got: string"));
636    }
637
638    #[test]
639    fn test_validation_error() {
640        let err = Error::validation("users[0].name", "must be at least 3 characters");
641        let display = format!("{}", err);
642
643        assert!(display.contains("Validation error"));
644        assert!(display.contains("Path: users[0].name"));
645        assert!(display.contains("must be at least 3 characters"));
646    }
647
648    #[test]
649    fn test_validation_error_root_path() {
650        // Root path should not show path field
651        let err = Error::validation("<root>", "missing required field");
652        assert!(err.path.is_none());
653
654        let err2 = Error::validation("", "missing required field");
655        assert!(err2.path.is_none());
656    }
657
658    #[test]
659    fn test_http_error_display() {
660        let err = Error {
661            kind: ErrorKind::Resolver(ResolverErrorKind::HttpError {
662                url: "https://example.com/config".into(),
663                status: Some(404),
664            }),
665            path: Some("remote.config".into()),
666            source_location: None,
667            help: None,
668            cause: None,
669        };
670        let display = format!("{}", err);
671
672        assert!(display.contains("HTTP request failed: https://example.com/config"));
673        assert!(display.contains("status 404"));
674    }
675
676    #[test]
677    fn test_http_disabled_error() {
678        let err = Error {
679            kind: ErrorKind::Resolver(ResolverErrorKind::HttpDisabled),
680            path: Some("remote.config".into()),
681            source_location: None,
682            help: None,
683            cause: None,
684        };
685        let display = format!("{}", err);
686
687        assert!(display.contains("HTTP resolver is disabled"));
688    }
689}