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
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 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 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 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 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 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 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 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 if let Some(path) = &self.path {
497 write!(f, "\n Path: {}", path)?;
498 }
499
500 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 if let Some(cause) = &self.cause {
510 write!(f, "\n {}", cause)?;
511 }
512
513 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 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 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}