1#![allow(unused_assignments)] use miette::{Diagnostic, NamedSource, SourceSpan};
4use std::sync::Arc;
5use thiserror::Error;
6
7#[derive(Error, Debug, Diagnostic)]
9#[error("{message}")]
10#[diagnostic(code(fnox::config::validation_issue))]
11pub struct ValidationIssue {
12 pub message: String,
13 #[help]
14 pub help: Option<String>,
15}
16
17impl ValidationIssue {
18 pub fn with_help(message: impl Into<String>, help: impl Into<String>) -> Self {
19 Self {
20 message: message.into(),
21 help: Some(help.into()),
22 }
23 }
24}
25
26#[derive(Error, Debug, Diagnostic)]
27pub enum FnoxError {
28 #[error("Configuration file not found: {}", path.display())]
32 #[diagnostic(
33 code(fnox::config::not_found),
34 help("Run 'fnox init' to create a new configuration file"),
35 url("https://fnox.jdx.dev/guide/quick-start")
36 )]
37 ConfigFileNotFound { path: std::path::PathBuf },
38
39 #[error("Failed to read configuration file: {}", path.display())]
40 #[diagnostic(
41 code(fnox::config::read_failed),
42 help("Ensure the config file exists and you have read permissions"),
43 url("https://fnox.jdx.dev/reference/configuration")
44 )]
45 ConfigReadFailed {
46 path: std::path::PathBuf,
47 #[source]
48 source: std::io::Error,
49 },
50
51 #[error("Failed to write configuration file: {}", path.display())]
52 #[diagnostic(
53 code(fnox::config::write_failed),
54 help("Check that you have write permissions for the config directory"),
55 url("https://fnox.jdx.dev/reference/configuration")
56 )]
57 ConfigWriteFailed {
58 path: std::path::PathBuf,
59 #[source]
60 source: std::io::Error,
61 },
62
63 #[error("Invalid TOML in configuration file")]
64 #[diagnostic(
65 code(fnox::config::invalid_toml),
66 help("Check the TOML syntax in your fnox.toml file"),
67 url("https://fnox.jdx.dev/reference/configuration")
68 )]
69 ConfigParseError {
70 #[source]
71 source: toml_edit::de::Error,
72 },
73
74 #[error("{message}")]
76 #[diagnostic(
77 code(fnox::config::invalid_toml),
78 help("Check the TOML syntax in your configuration file"),
79 url("https://fnox.jdx.dev/reference/configuration")
80 )]
81 ConfigParseErrorWithSource {
82 message: String,
83 #[source_code]
84 src: Arc<NamedSource<Arc<String>>>,
85 #[label("parse error here")]
86 span: SourceSpan,
87 },
88
89 #[error("Failed to serialize configuration to TOML")]
90 #[diagnostic(
91 code(fnox::config::serialize_failed),
92 url("https://fnox.jdx.dev/reference/configuration")
93 )]
94 ConfigSerializeError {
95 #[source]
96 source: toml_edit::ser::Error,
97 },
98
99 #[error("Configuration validation failed ({})", pluralizer::pluralize("issue", std::cmp::min(issues.len(), isize::MAX as usize) as isize, true))]
102 #[diagnostic(
103 code(fnox::config::validation_failed),
104 help("Fix the issues above in your fnox.toml file"),
105 url("https://fnox.jdx.dev/reference/configuration")
106 )]
107 ConfigValidationFailed {
108 #[related]
109 issues: Vec<ValidationIssue>,
110 },
111
112 #[error("{message}")]
114 #[diagnostic(help("{help}"))]
115 ConfigNotFound { message: String, help: String },
116
117 #[error("Configuration error: {0}")]
119 #[diagnostic(code(fnox::config::error))]
120 Config(String),
121
122 #[error("Secret '{key}' not found in profile '{profile}'{}",
130 config_path.as_ref()
131 .map(|p| format!("\n Config file: {}", p.display()))
132 .unwrap_or_else(|| "\n (not defined in any config file)".to_string())
133 )]
134 #[diagnostic(
135 code(fnox::secret::not_found),
136 help(
137 "{suggestion}{init_help}Available actions:\n • View defined secrets: fnox list -P {profile} --sources\n • Add this secret: fnox set {key} <value> -P {profile}{file_suggest}",
138 suggestion = suggestion.as_ref()
139 .map(|s| format!("{}\n\n", s))
140 .unwrap_or_default(),
141 init_help = if config_path.is_none() {
142 "No configuration file found. Create one with:\n • fnox init\n\n"
143 } else {
144 ""
145 },
146 file_suggest = config_path.as_ref()
147 .map(|p| format!("\n • Edit config file: {}", p.display()))
148 .unwrap_or_default()
149 ),
150 url("https://fnox.jdx.dev/guide/what-is-fnox")
151 )]
152 SecretNotFound {
153 key: String,
154 profile: String,
155 config_path: Option<std::path::PathBuf>,
156 suggestion: Option<String>,
157 },
158
159 #[error("Failed to decode secret: {details}")]
160 #[diagnostic(code(fnox::secret::decode_failed))]
161 SecretDecodeFailed { details: String },
162
163 #[error("Provider '{provider}' not configured in profile '{profile}'{}",
167 config_path.as_ref()
168 .map(|p| format!("\n Config file: {}", p.display()))
169 .unwrap_or_else(|| "\n (provider not defined in any config file)".to_string())
170 )]
171 #[diagnostic(
172 code(fnox::provider::not_configured),
173 help(
174 "{suggestion}To configure this provider:\n \
175 1. Add provider configuration to your fnox.toml:\n \
176 [profiles.{profile}.providers.{provider}]\n \
177 type = \"age\" # or other provider type\n \
178 2. Or configure it globally:\n \
179 [providers.{provider}]\n \
180 type = \"age\"{file}",
181 suggestion = suggestion.as_ref()
182 .map(|s| format!("{}\n\n", s))
183 .unwrap_or_default(),
184 file = config_path.as_ref()
185 .map(|p| format!("\n Edit: {}", p.display()))
186 .unwrap_or_default()
187 ),
188 url("https://fnox.jdx.dev/providers/overview")
189 )]
190 ProviderNotConfigured {
191 provider: String,
192 profile: String,
193 config_path: Option<std::path::PathBuf>,
194 suggestion: Option<String>,
195 },
196
197 #[error("Provider '{provider}' not configured in profile '{profile}'")]
199 #[diagnostic(
200 code(fnox::provider::not_configured),
201 help(
202 "{suggestion}Add the provider to your config:\n \
203 [providers.{provider}]\n \
204 type = \"age\" # or other provider type",
205 suggestion = suggestion.as_ref()
206 .map(|s| format!("{}\n\n", s))
207 .unwrap_or_default()
208 ),
209 url("https://fnox.jdx.dev/providers")
210 )]
211 ProviderNotConfiguredWithSource {
212 provider: String,
213 profile: String,
214 suggestion: Option<String>,
215 #[source_code]
216 src: Arc<NamedSource<Arc<String>>>,
217 #[label("provider '{provider}' referenced here")]
218 span: SourceSpan,
219 },
220
221 #[error("Default provider '{provider}' not found in profile '{profile}'")]
223 #[diagnostic(
224 code(fnox::config::default_provider_not_found),
225 help(
226 "The configured default_provider references a provider that doesn't exist.\n\
227 Add the provider to your config:\n \
228 [providers.{provider}]\n \
229 type = \"age\" # or other provider type"
230 ),
231 url("https://fnox.jdx.dev/providers")
232 )]
233 DefaultProviderNotFoundWithSource {
234 provider: String,
235 profile: String,
236 #[source_code]
237 src: Arc<NamedSource<Arc<String>>>,
238 #[label("default_provider '{provider}' set here, but no such provider exists")]
239 span: SourceSpan,
240 },
241
242 #[error("Provider error: {0}")]
244 #[diagnostic(code(fnox::provider::error))]
245 Provider(String),
246
247 #[error("{provider}: CLI tool '{cli}' not found")]
248 #[diagnostic(
249 code(fnox::provider::cli_not_found),
250 help(
251 "Install the {cli} CLI tool:\n \
252 {install_hint}"
253 ),
254 url("{url}")
255 )]
256 ProviderCliNotFound {
257 provider: String,
258 cli: String,
259 install_hint: String,
260 url: String,
261 },
262
263 #[error("{provider}: command failed: {details}")]
264 #[diagnostic(code(fnox::provider::cli_failed), help("{hint}"), url("{url}"))]
265 ProviderCliFailed {
266 provider: String,
267 details: String,
268 hint: String,
269 url: String,
270 },
271
272 #[error("{provider}: authentication failed: {details}")]
273 #[diagnostic(code(fnox::provider::auth_failed), help("{hint}"), url("{url}"))]
274 ProviderAuthFailed {
275 provider: String,
276 details: String,
277 hint: String,
278 url: String,
279 },
280
281 #[error("{provider}: secret '{secret}' not found")]
282 #[diagnostic(
283 code(fnox::provider::secret_not_found),
284 help(
285 "The secret '{secret}' does not exist in {provider}.\n\
286 {hint}"
287 ),
288 url("{url}")
289 )]
290 ProviderSecretNotFound {
291 provider: String,
292 secret: String,
293 hint: String,
294 url: String,
295 },
296
297 #[error("{provider}: invalid response: {details}")]
298 #[diagnostic(code(fnox::provider::invalid_response), help("{hint}"), url("{url}"))]
299 ProviderInvalidResponse {
300 provider: String,
301 details: String,
302 hint: String,
303 url: String,
304 },
305
306 #[error("{provider}: API error: {details}")]
307 #[diagnostic(code(fnox::provider::api_error), help("{hint}"), url("{url}"))]
308 ProviderApiError {
309 provider: String,
310 details: String,
311 hint: String,
312 url: String,
313 },
314
315 #[error("Circular dependency detected in provider configuration for '{provider}'")]
316 #[diagnostic(
317 code(fnox::provider::config_cycle),
318 help(
319 "Resolution path: {cycle}\n\
320 Break the cycle by using a literal value or environment variable for one provider."
321 ),
322 url("https://fnox.jdx.dev/guide/what-is-fnox")
323 )]
324 ProviderConfigCycle { provider: String, cycle: String },
325
326 #[error(
327 "Failed to resolve secret '{secret}' for provider '{provider}' configuration: {details}"
328 )]
329 #[diagnostic(
330 code(fnox::provider::config_resolution_failed),
331 help(
332 "Ensure the secret '{secret}' is defined in your config or as an environment variable"
333 ),
334 url("https://fnox.jdx.dev/guide/what-is-fnox")
335 )]
336 ProviderConfigResolutionFailed {
337 provider: String,
338 secret: String,
339 details: String,
340 },
341
342 #[error("Age encryption is not configured")]
346 #[diagnostic(
347 code(fnox::encryption::age::not_configured),
348 help(
349 "Add age encryption to your config:\n [encryption]\n type = \"age\"\n key_file = \"age.txt\""
350 ),
351 url("https://fnox.jdx.dev/providers/age")
352 )]
353 AgeNotConfigured,
354
355 #[error("Age identity file not found: {}", path.display())]
356 #[diagnostic(
357 code(fnox::encryption::age::identity_not_found),
358 help("Create an age identity with: age-keygen -o {}", crate::env::FNOX_CONFIG_DIR.join("age.txt").display()),
359 url("https://github.com/FiloSottile/age")
360 )]
361 AgeIdentityNotFound { path: std::path::PathBuf },
362
363 #[error("Failed to read age identity file: {}", path.display())]
364 #[diagnostic(
365 code(fnox::encryption::age::identity_read_failed),
366 help("Ensure the identity file exists and is readable"),
367 url("https://fnox.jdx.dev/providers/age")
368 )]
369 AgeIdentityReadFailed {
370 path: std::path::PathBuf,
371 #[source]
372 source: std::io::Error,
373 },
374
375 #[error("Failed to parse age identity: {details}")]
376 #[diagnostic(
377 code(fnox::encryption::age::identity_parse_failed),
378 help("Ensure the identity file contains a valid age secret key"),
379 url("https://fnox.jdx.dev/providers/age")
380 )]
381 AgeIdentityParseFailed { details: String },
382
383 #[error("Age encryption failed: {details}")]
384 #[diagnostic(
385 code(fnox::encryption::age::encrypt_failed),
386 help("Ensure your age public key is configured correctly"),
387 url("https://fnox.jdx.dev/providers/age")
388 )]
389 AgeEncryptionFailed { details: String },
390
391 #[error("Age decryption failed: {details}")]
392 #[diagnostic(
393 code(fnox::encryption::age::decrypt_failed),
394 help(
395 "Ensure you have the correct age identity file or FNOX_AGE_KEY environment variable set"
396 ),
397 url("https://fnox.jdx.dev/providers/age")
398 )]
399 AgeDecryptionFailed { details: String },
400
401 #[error("Failed to launch editor: {editor}")]
405 #[diagnostic(code(fnox::editor::launch_failed))]
406 EditorLaunchFailed {
407 editor: String,
408 #[source]
409 source: std::io::Error,
410 },
411
412 #[error("Editor exited with non-zero status: {status}")]
413 #[diagnostic(code(fnox::editor::exit_failed))]
414 EditorExitFailed { editor: String, status: i32 },
415
416 #[error("Lease '{lease}' produced credentials but key '{key}' was absent")]
420 #[diagnostic(
421 code(fnox::lease::contract_violation),
422 help(
423 "The lease backend '{lease}' declared it would produce env var '{key}' \
424 (via produces_env_var()), but the credential map returned at runtime \
425 did not contain it. For Vault backends, verify that the remote secret \
426 path contains the key specified in your env_map configuration. For \
427 other backends, this may indicate a bug in the backend implementation."
428 )
429 )]
430 LeaseContractViolation { lease: String, key: String },
431
432 #[error("No command specified")]
436 #[diagnostic(
437 code(fnox::command::not_specified),
438 help("Provide a command to run with your secrets. Example: fnox exec -- npm start"),
439 url("https://fnox.jdx.dev/cli/exec")
440 )]
441 CommandNotSpecified,
442
443 #[error("Command execution failed: {command}")]
444 #[diagnostic(code(fnox::command::execution_failed))]
445 CommandExecutionFailed {
446 command: String,
447 #[source]
448 source: std::io::Error,
449 },
450
451 #[error("When importing from stdin, --force or --dry-run is required")]
455 #[diagnostic(
456 code(fnox::import::stdin_requires_force),
457 help(
458 "Stdin is consumed during import and cannot be used for the confirmation prompt.\n\n\
459 Use: fnox import --force < input.env\n\
460 Or: fnox import --dry-run < input.env (to preview without changes)\n\
461 Or: cat input.env | fnox import --force"
462 ),
463 url("https://fnox.jdx.dev/cli/import")
464 )]
465 ImportStdinRequiresForce,
466
467 #[error("Invalid regex filter pattern: {pattern}: {details}")]
468 #[diagnostic(
469 code(fnox::filter::invalid_regex),
470 help("Ensure the filter is a valid regular expression")
471 )]
472 InvalidRegexFilter { pattern: String, details: String },
473
474 #[error("Failed to read import source: {}", path.display())]
475 #[diagnostic(
476 code(fnox::import::read_failed),
477 help("Ensure the file exists and you have read permissions"),
478 url("https://fnox.jdx.dev/cli/import")
479 )]
480 ImportReadFailed {
481 path: std::path::PathBuf,
482 #[source]
483 source: std::io::Error,
484 },
485
486 #[error("Failed to encrypt secret '{key}' with provider '{provider}': {details}")]
487 #[diagnostic(
488 code(fnox::import::encryption_failed),
489 help("Check the provider configuration and ensure the encryption key is available"),
490 url("https://fnox.jdx.dev/cli/import")
491 )]
492 ImportEncryptionFailed {
493 key: String,
494 provider: String,
495 details: String,
496 },
497
498 #[error("Failed to parse {format} input: {details}")]
500 #[diagnostic(
501 code(fnox::import::parse_failed),
502 help("Check the {format} syntax in the input file"),
503 url("https://fnox.jdx.dev/cli/import")
504 )]
505 ImportParseErrorWithSource {
506 format: String,
507 details: String,
508 #[source_code]
509 src: Arc<NamedSource<Arc<String>>>,
510 #[label("parse error here")]
511 span: SourceSpan,
512 },
513
514 #[error("Provider '{provider}' cannot be used for import")]
515 #[diagnostic(
516 code(fnox::import::provider_unsupported),
517 help("{help}"),
518 url("https://fnox.jdx.dev/cli/import")
519 )]
520 ImportProviderUnsupported { provider: String, help: String },
521
522 #[error("Provider '{provider}' cannot be used as a sync target")]
526 #[diagnostic(
527 code(fnox::sync::target_unsupported),
528 help(
529 "The target provider must support encryption (e.g., 'age', 'aws-kms'). Remote storage providers cannot be used as sync targets."
530 ),
531 url("https://fnox.jdx.dev/cli/sync")
532 )]
533 SyncTargetProviderUnsupported { provider: String },
534
535 #[error("Failed to encrypt secret '{key}' with provider '{provider}': {details}")]
536 #[diagnostic(
537 code(fnox::sync::encryption_failed),
538 help("Check the provider configuration and ensure the encryption key is available"),
539 url("https://fnox.jdx.dev/cli/sync")
540 )]
541 SyncEncryptionFailed {
542 key: String,
543 provider: String,
544 details: String,
545 },
546
547 #[error("Failed to re-encrypt secret '{key}' with provider '{provider}': {details}")]
551 #[diagnostic(
552 code(fnox::reencrypt::encryption_failed),
553 help(
554 "Check the provider configuration and ensure the encryption key/recipients are available"
555 ),
556 url("https://fnox.jdx.dev/cli/reencrypt")
557 )]
558 ReencryptEncryptionFailed {
559 key: String,
560 provider: String,
561 details: String,
562 },
563
564 #[error("Failed to decrypt secret '{key}' — cannot re-encrypt: {details}")]
565 #[diagnostic(
566 code(fnox::reencrypt::decrypt_failed),
567 help("Ensure you have the correct private key for the current recipients"),
568 url("https://fnox.jdx.dev/cli/reencrypt")
569 )]
570 ReencryptDecryptFailed { key: String, details: String },
571
572 #[error("Failed to create directory: {}", path.display())]
573 #[diagnostic(
574 code(fnox::io::create_dir_failed),
575 help("Ensure you have write permissions for the parent directory")
576 )]
577 CreateDirFailed {
578 path: std::path::PathBuf,
579 #[source]
580 source: std::io::Error,
581 },
582
583 #[error("Failed to write export to file: {}", path.display())]
587 #[diagnostic(
588 code(fnox::export::write_failed),
589 help("Ensure you have write permissions for the output path"),
590 url("https://fnox.jdx.dev/cli/export")
591 )]
592 ExportWriteFailed {
593 path: std::path::PathBuf,
594 #[source]
595 source: std::io::Error,
596 },
597
598 #[error("Failed to read from stdin")]
599 #[diagnostic(code(fnox::io::stdin_read_failed))]
600 StdinReadFailed {
601 #[source]
602 source: std::io::Error,
603 },
604
605 #[error("I/O error: {0}")]
609 #[diagnostic(code(fnox::io::error))]
610 Io(#[from] std::io::Error),
611
612 #[error("JSON error")]
616 #[diagnostic(code(fnox::json::error))]
617 Json {
618 #[source]
619 source: serde_json::Error,
620 },
621
622 #[error("YAML error")]
623 #[diagnostic(code(fnox::yaml::error))]
624 Yaml {
625 #[source]
626 source: serde_yaml::Error,
627 },
628
629 #[error("TOML serialization error")]
630 #[diagnostic(code(fnox::toml::error))]
631 Toml {
632 #[source]
633 source: toml_edit::ser::Error,
634 },
635}
636
637impl From<serde_json::Error> for FnoxError {
639 fn from(source: serde_json::Error) -> Self {
640 FnoxError::Json { source }
641 }
642}
643
644impl From<serde_yaml::Error> for FnoxError {
645 fn from(source: serde_yaml::Error) -> Self {
646 FnoxError::Yaml { source }
647 }
648}
649
650impl From<toml_edit::de::Error> for FnoxError {
651 fn from(source: toml_edit::de::Error) -> Self {
652 FnoxError::ConfigParseError { source }
653 }
654}
655
656impl From<toml_edit::ser::Error> for FnoxError {
657 fn from(source: toml_edit::ser::Error) -> Self {
658 FnoxError::ConfigSerializeError { source }
659 }
660}
661
662impl From<miette::ErrReport> for FnoxError {
665 fn from(err: miette::ErrReport) -> Self {
666 FnoxError::Config(format!("{}", err))
667 }
668}
669
670impl FnoxError {
671 pub fn is_auth_error(&self) -> bool {
673 matches!(self, FnoxError::ProviderAuthFailed { .. })
674 }
675
676 pub fn clone_provider_error(&self) -> Option<FnoxError> {
680 Some(match self {
681 FnoxError::ProviderAuthFailed {
682 provider,
683 details,
684 hint,
685 url,
686 } => FnoxError::ProviderAuthFailed {
687 provider: provider.clone(),
688 details: details.clone(),
689 hint: hint.clone(),
690 url: url.clone(),
691 },
692 FnoxError::ProviderCliNotFound {
693 provider,
694 cli,
695 install_hint,
696 url,
697 } => FnoxError::ProviderCliNotFound {
698 provider: provider.clone(),
699 cli: cli.clone(),
700 install_hint: install_hint.clone(),
701 url: url.clone(),
702 },
703 FnoxError::ProviderInvalidResponse {
704 provider,
705 details,
706 hint,
707 url,
708 } => FnoxError::ProviderInvalidResponse {
709 provider: provider.clone(),
710 details: details.clone(),
711 hint: hint.clone(),
712 url: url.clone(),
713 },
714 FnoxError::ProviderApiError {
715 provider,
716 details,
717 hint,
718 url,
719 } => FnoxError::ProviderApiError {
720 provider: provider.clone(),
721 details: details.clone(),
722 hint: hint.clone(),
723 url: url.clone(),
724 },
725 FnoxError::ProviderCliFailed {
726 provider,
727 details,
728 hint,
729 url,
730 } => FnoxError::ProviderCliFailed {
731 provider: provider.clone(),
732 details: details.clone(),
733 hint: hint.clone(),
734 url: url.clone(),
735 },
736 _ => return None,
737 })
738 }
739
740 pub fn map_batch_error(
746 &self,
747 secret_name: &str,
748 fallback_provider: &str,
749 fallback_hint: &str,
750 fallback_url: &str,
751 ) -> FnoxError {
752 if let FnoxError::ProviderSecretNotFound {
753 provider,
754 hint,
755 url,
756 ..
757 } = self
758 {
759 return FnoxError::ProviderSecretNotFound {
760 provider: provider.clone(),
761 secret: secret_name.to_string(),
762 hint: hint.clone(),
763 url: url.clone(),
764 };
765 }
766
767 self.clone_provider_error()
768 .unwrap_or_else(|| FnoxError::ProviderCliFailed {
769 provider: fallback_provider.to_string(),
770 details: self.to_string(),
771 hint: fallback_hint.to_string(),
772 url: fallback_url.to_string(),
773 })
774 }
775}
776
777pub type Result<T> = std::result::Result<T, FnoxError>;
778
779#[cfg(test)]
780mod tests {
781 use super::*;
782
783 #[test]
784 fn is_auth_error_returns_true_for_provider_auth_failed() {
785 let err = FnoxError::ProviderAuthFailed {
786 provider: "test".to_string(),
787 details: "unauthorized".to_string(),
788 hint: "login".to_string(),
789 url: "https://example.com".to_string(),
790 };
791 assert!(err.is_auth_error());
792 }
793
794 #[test]
795 fn is_auth_error_returns_false_for_other_variants() {
796 let cases: Vec<FnoxError> = vec![
797 FnoxError::ProviderSecretNotFound {
798 provider: "test".to_string(),
799 secret: "MY_SECRET".to_string(),
800 hint: "check".to_string(),
801 url: "https://example.com".to_string(),
802 },
803 FnoxError::ProviderCliFailed {
804 provider: "test".to_string(),
805 details: "exit 1".to_string(),
806 hint: "check".to_string(),
807 url: "https://example.com".to_string(),
808 },
809 FnoxError::ProviderInvalidResponse {
810 provider: "test".to_string(),
811 details: "bad json".to_string(),
812 hint: "check".to_string(),
813 url: "https://example.com".to_string(),
814 },
815 FnoxError::ProviderCliNotFound {
816 provider: "test".to_string(),
817 cli: "op".to_string(),
818 install_hint: "brew install".to_string(),
819 url: "https://example.com".to_string(),
820 },
821 FnoxError::Provider("generic error".to_string()),
822 ];
823
824 for err in cases {
825 assert!(!err.is_auth_error(), "Expected false for {:?}", err);
826 }
827 }
828
829 #[test]
830 fn clone_provider_error_clones_auth_failed() {
831 let err = FnoxError::ProviderAuthFailed {
832 provider: "test".to_string(),
833 details: "unauthorized".to_string(),
834 hint: "login".to_string(),
835 url: "https://example.com".to_string(),
836 };
837 assert!(matches!(
838 err.clone_provider_error(),
839 Some(FnoxError::ProviderAuthFailed { .. })
840 ));
841 }
842
843 #[test]
844 fn clone_provider_error_returns_none_for_non_provider() {
845 let err = FnoxError::Provider("generic".to_string());
846 assert!(err.clone_provider_error().is_none());
847 }
848
849 #[test]
850 fn map_batch_error_replaces_secret_name() {
851 let err = FnoxError::ProviderSecretNotFound {
852 provider: "test".to_string(),
853 secret: "original".to_string(),
854 hint: "check".to_string(),
855 url: "https://example.com".to_string(),
856 };
857 let mapped = err.map_batch_error("new_secret", "test", "hint", "url");
858 match mapped {
859 FnoxError::ProviderSecretNotFound { secret, .. } => {
860 assert_eq!(secret, "new_secret");
861 }
862 other => panic!("Expected ProviderSecretNotFound, got {:?}", other),
863 }
864 }
865
866 #[test]
867 fn map_batch_error_falls_back_for_non_provider() {
868 let err = FnoxError::Provider("generic".to_string());
869 let mapped = err.map_batch_error("secret", "MyProvider", "check config", "https://x.com");
870 match mapped {
871 FnoxError::ProviderCliFailed { provider, hint, .. } => {
872 assert_eq!(provider, "MyProvider");
873 assert_eq!(hint, "check config");
874 }
875 other => panic!("Expected ProviderCliFailed, got {:?}", other),
876 }
877 }
878}