1use std::fmt;
7
8pub type Result<T> = std::result::Result<T, Error>;
10
11#[derive(Debug, Clone)]
13pub struct Error {
14 pub kind: ErrorKind,
16 pub path: Option<String>,
18 pub source_location: Option<SourceLocation>,
20 pub help: Option<String>,
22 pub cause: Option<String>,
24}
25
26#[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#[derive(Debug, Clone, PartialEq, Eq)]
36pub enum ErrorKind {
37 Parse,
39 Resolver(ResolverErrorKind),
41 Validation,
43 PathNotFound,
45 CircularReference,
47 TypeCoercion,
49 Io,
51 Internal,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum ResolverErrorKind {
58 NotFound { resource: String },
62 EnvNotFound { var_name: String },
64 FileNotFound { path: String },
66 HttpError { url: String, status: Option<u16> },
68 HttpDisabled,
70 HttpNotAllowed { url: String },
72 TlsConfigError { message: String },
74 ProxyConfigError { message: String },
76 PemLoadError { path: String, message: String },
78 P12LoadError { path: String, message: String },
80 KeyDecryptionError { message: String },
82 RefNotFound { ref_path: String },
84 UnknownResolver { name: String },
86 Custom { resolver: String, message: String },
88 AlreadyRegistered { name: String },
90}
91
92impl Error {
93 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 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 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 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 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 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 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 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 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 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 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 pub fn with_path(mut self, path: impl Into<String>) -> Self {
252 self.path = Some(path.into());
253 self
254 }
255
256 pub fn with_source_location(mut self, loc: SourceLocation) -> Self {
258 self.source_location = Some(loc);
259 self
260 }
261
262 pub fn with_help(mut self, help: impl Into<String>) -> Self {
264 self.help = Some(help.into());
265 self
266 }
267
268 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 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 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 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 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 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 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 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 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 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 if let Some(path) = &self.path {
485 write!(f, "\n Path: {}", path)?;
486 }
487
488 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 if let Some(cause) = &self.cause {
498 write!(f, "\n {}", cause)?;
499 }
500
501 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 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 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}