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
321        let help_msg = if let Some(ref path) = config_path {
322            format!(
323                "The URL specified by '{}' is not in the allowlist.\n\
324                 Update the allowlist or change the URL to match an allowed pattern.\n\
325                 Current allowlist patterns: {}\n\
326                 Use 'holoconf dump --include-sources' to see which file contains this value.",
327                path, allowlist_str
328            )
329        } else {
330            format!(
331                "The URL is not in the allowlist.\n\
332                 Current allowlist patterns: {}",
333                allowlist_str
334            )
335        };
336
337        Self {
338            kind: ErrorKind::Resolver(ResolverErrorKind::HttpNotAllowed { url: url_str }),
339            path: config_path,
340            source_location: None,
341            help: Some(help_msg),
342            cause: None,
343        }
344    }
345
346    /// Create a TLS configuration error
347    pub fn tls_config_error(message: impl Into<String>) -> Self {
348        Self {
349            kind: ErrorKind::Resolver(ResolverErrorKind::TlsConfigError {
350                message: message.into(),
351            }),
352            path: None,
353            source_location: None,
354            help: Some("Check your TLS configuration (CA bundles, client certificates)".into()),
355            cause: None,
356        }
357    }
358
359    /// Create a proxy configuration error
360    pub fn proxy_config_error(message: impl Into<String>) -> Self {
361        Self {
362            kind: ErrorKind::Resolver(ResolverErrorKind::ProxyConfigError {
363                message: message.into(),
364            }),
365            path: None,
366            source_location: None,
367            help: Some(
368                "Check your proxy URL format (e.g., http://proxy:8080 or socks5://proxy:1080)"
369                    .into(),
370            ),
371            cause: None,
372        }
373    }
374
375    /// Create a PEM file loading error
376    pub fn pem_load_error(path: impl Into<String>, message: impl Into<String>) -> Self {
377        let path_str = path.into();
378        Self {
379            kind: ErrorKind::Resolver(ResolverErrorKind::PemLoadError {
380                path: path_str.clone(),
381                message: message.into(),
382            }),
383            path: None,
384            source_location: None,
385            help: Some(format!(
386                "Ensure '{}' exists and contains valid PEM-encoded data",
387                path_str
388            )),
389            cause: None,
390        }
391    }
392
393    /// Create a P12/PFX file loading error
394    pub fn p12_load_error(path: impl Into<String>, message: impl Into<String>) -> Self {
395        let path_str = path.into();
396        Self {
397            kind: ErrorKind::Resolver(ResolverErrorKind::P12LoadError {
398                path: path_str.clone(),
399                message: message.into(),
400            }),
401            path: None,
402            source_location: None,
403            help: Some(format!(
404                "Ensure '{}' exists and contains a valid PKCS#12/PFX bundle with the correct password",
405                path_str
406            )),
407            cause: None,
408        }
409    }
410
411    /// Create a key decryption error
412    pub fn key_decryption_error(message: impl Into<String>) -> Self {
413        Self {
414            kind: ErrorKind::Resolver(ResolverErrorKind::KeyDecryptionError {
415                message: message.into(),
416            }),
417            path: None,
418            source_location: None,
419            help: Some("Check that the password is correct for the encrypted private key".into()),
420            cause: None,
421        }
422    }
423
424    /// Create an internal error (bug in holoconf)
425    pub fn internal(message: impl Into<String>) -> Self {
426        Self {
427            kind: ErrorKind::Internal,
428            path: None,
429            source_location: None,
430            help: Some("This is likely a bug in holoconf. Please report it.".into()),
431            cause: Some(message.into()),
432        }
433    }
434}
435
436impl fmt::Display for Error {
437    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
438        // Main error message
439        match &self.kind {
440            ErrorKind::Parse => write!(f, "Parse error")?,
441            ErrorKind::Resolver(r) => match r {
442                ResolverErrorKind::NotFound { resource } => {
443                    write!(f, "Resource not found: {}", resource)?
444                }
445                ResolverErrorKind::EnvNotFound { var_name } => {
446                    write!(f, "Environment variable not found: {}", var_name)?
447                }
448                ResolverErrorKind::FileNotFound { path } => write!(f, "File not found: {}", path)?,
449                ResolverErrorKind::HttpError { url, status } => {
450                    write!(f, "HTTP request failed: {}", url)?;
451                    if let Some(s) = status {
452                        write!(f, " (status {})", s)?;
453                    }
454                }
455                ResolverErrorKind::HttpDisabled => write!(f, "HTTP resolver is disabled")?,
456                ResolverErrorKind::HttpNotAllowed { url } => {
457                    write!(f, "URL not in allowlist: {}", url)?
458                }
459                ResolverErrorKind::RefNotFound { ref_path } => {
460                    write!(f, "Referenced path not found: {}", ref_path)?
461                }
462                ResolverErrorKind::UnknownResolver { name } => {
463                    write!(f, "Unknown resolver: {}", name)?
464                }
465                ResolverErrorKind::Custom { resolver, message } => {
466                    write!(f, "Resolver '{}' error: {}", resolver, message)?
467                }
468                ResolverErrorKind::AlreadyRegistered { name } => {
469                    write!(f, "Resolver '{}' is already registered", name)?
470                }
471                ResolverErrorKind::TlsConfigError { message } => {
472                    write!(f, "TLS configuration error: {}", message)?
473                }
474                ResolverErrorKind::ProxyConfigError { message } => {
475                    write!(f, "Proxy configuration error: {}", message)?
476                }
477                ResolverErrorKind::PemLoadError { path, message } => {
478                    write!(f, "Failed to load PEM file '{}': {}", path, message)?
479                }
480                ResolverErrorKind::P12LoadError { path, message } => {
481                    write!(f, "Failed to load P12/PFX file '{}': {}", path, message)?
482                }
483                ResolverErrorKind::KeyDecryptionError { message } => {
484                    write!(f, "Failed to decrypt private key: {}", message)?
485                }
486            },
487            ErrorKind::Validation => write!(f, "Validation error")?,
488            ErrorKind::PathNotFound => write!(f, "Path not found")?,
489            ErrorKind::CircularReference => write!(f, "Circular reference detected")?,
490            ErrorKind::TypeCoercion => write!(f, "Type coercion failed")?,
491            ErrorKind::Io => write!(f, "I/O error")?,
492            ErrorKind::Internal => write!(f, "Internal error")?,
493        }
494
495        // Path context
496        if let Some(path) = &self.path {
497            write!(f, "\n  Path: {}", path)?;
498        }
499
500        // Source location
501        if let Some(loc) = &self.source_location {
502            write!(f, "\n  File: {}", loc.file)?;
503            if let Some(line) = loc.line {
504                write!(f, ":{}", line)?;
505            }
506        }
507
508        // Cause
509        if let Some(cause) = &self.cause {
510            write!(f, "\n  {}", cause)?;
511        }
512
513        // Help
514        if let Some(help) = &self.help {
515            write!(f, "\n  Help: {}", help)?;
516        }
517
518        Ok(())
519    }
520}
521
522impl std::error::Error for Error {}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527
528    #[test]
529    fn test_env_not_found_error_display() {
530        let err = Error::env_not_found("MY_VAR", Some("database.password".into()));
531        let display = format!("{}", err);
532
533        assert!(display.contains("Environment variable not found: MY_VAR"));
534        assert!(display.contains("Path: database.password"));
535        assert!(display.contains("Help:"));
536        assert!(display.contains("${env:MY_VAR,default=value}"));
537    }
538
539    #[test]
540    fn test_circular_reference_error_display() {
541        let err = Error::circular_reference(
542            "config.a",
543            vec!["a".into(), "b".into(), "c".into(), "a".into()],
544        );
545        let display = format!("{}", err);
546
547        assert!(display.contains("Circular reference detected"));
548        assert!(display.contains("a → b → c → a"));
549    }
550
551    #[test]
552    fn test_path_not_found_error() {
553        let err = Error::path_not_found("database.host");
554
555        assert_eq!(err.kind, ErrorKind::PathNotFound);
556        assert_eq!(err.path, Some("database.host".into()));
557    }
558
559    #[test]
560    fn test_not_found_error() {
561        let err = Error::not_found("my-resource", Some("config.key".into()));
562        let display = format!("{}", err);
563
564        assert!(display.contains("Resource not found: my-resource"));
565        assert!(display.contains("Path: config.key"));
566        assert!(matches!(
567            err.kind,
568            ErrorKind::Resolver(ResolverErrorKind::NotFound { .. })
569        ));
570    }
571
572    #[test]
573    fn test_ref_not_found_error() {
574        let err = Error::ref_not_found("database.missing", Some("app.db".into()));
575        let display = format!("{}", err);
576
577        assert!(display.contains("Referenced path not found: database.missing"));
578        assert!(display.contains("Path: app.db"));
579        assert!(display.contains("Help:"));
580    }
581
582    #[test]
583    fn test_file_not_found_error() {
584        let err = Error::file_not_found("/path/to/missing.yaml", Some("config.file".into()));
585        let display = format!("{}", err);
586
587        assert!(display.contains("File not found: /path/to/missing.yaml"));
588        assert!(display.contains("Path: config.file"));
589    }
590
591    #[test]
592    fn test_unknown_resolver_error() {
593        let err = Error::unknown_resolver("unknown", Some("config.value".into()));
594        let display = format!("{}", err);
595
596        assert!(display.contains("Unknown resolver: unknown"));
597        assert!(display.contains("Help:"));
598        assert!(display.contains("Register the 'unknown' resolver"));
599    }
600
601    #[test]
602    fn test_resolver_custom_error() {
603        let err = Error::resolver_custom("myresolver", "Something went wrong");
604        let display = format!("{}", err);
605
606        assert!(display.contains("Resolver 'myresolver' error: Something went wrong"));
607        assert!(display.contains("Help:"));
608    }
609
610    #[test]
611    fn test_internal_error() {
612        let err = Error::internal("Unexpected state");
613        let display = format!("{}", err);
614
615        assert!(display.contains("Internal error"));
616        // The message goes into the cause field
617        assert!(display.contains("Unexpected state"));
618    }
619
620    #[test]
621    fn test_with_source_location() {
622        let err = Error::parse("syntax error").with_source_location(SourceLocation {
623            file: "config.yaml".into(),
624            line: Some(42),
625            column: None,
626        });
627        let display = format!("{}", err);
628
629        assert!(display.contains("config.yaml:42"));
630    }
631
632    #[test]
633    fn test_with_help() {
634        let err = Error::parse("bad input").with_help("Try fixing the syntax");
635        let display = format!("{}", err);
636
637        assert!(display.contains("Help: Try fixing the syntax"));
638    }
639
640    #[test]
641    fn test_type_coercion_error() {
642        let err = Error::type_coercion("server.port", "integer", "string");
643        let display = format!("{}", err);
644
645        assert!(display.contains("Type coercion failed"));
646        assert!(display.contains("Path: server.port"));
647        assert!(display.contains("Got: string"));
648    }
649
650    #[test]
651    fn test_validation_error() {
652        let err = Error::validation("users[0].name", "must be at least 3 characters");
653        let display = format!("{}", err);
654
655        assert!(display.contains("Validation error"));
656        assert!(display.contains("Path: users[0].name"));
657        assert!(display.contains("must be at least 3 characters"));
658    }
659
660    #[test]
661    fn test_validation_error_root_path() {
662        // Root path should not show path field
663        let err = Error::validation("<root>", "missing required field");
664        assert!(err.path.is_none());
665
666        let err2 = Error::validation("", "missing required field");
667        assert!(err2.path.is_none());
668    }
669
670    #[test]
671    fn test_http_error_display() {
672        let err = Error {
673            kind: ErrorKind::Resolver(ResolverErrorKind::HttpError {
674                url: "https://example.com/config".into(),
675                status: Some(404),
676            }),
677            path: Some("remote.config".into()),
678            source_location: None,
679            help: None,
680            cause: None,
681        };
682        let display = format!("{}", err);
683
684        assert!(display.contains("HTTP request failed: https://example.com/config"));
685        assert!(display.contains("status 404"));
686    }
687
688    #[test]
689    fn test_http_disabled_error() {
690        let err = Error {
691            kind: ErrorKind::Resolver(ResolverErrorKind::HttpDisabled),
692            path: Some("remote.config".into()),
693            source_location: None,
694            help: None,
695            cause: None,
696        };
697        let display = format!("{}", err);
698
699        assert!(display.contains("HTTP resolver is disabled"));
700    }
701}