Skip to main content

fnox_core/
error.rs

1#![allow(unused_assignments)] // Fields are used by thiserror/miette macros but clippy doesn't see it
2
3use miette::{Diagnostic, NamedSource, SourceSpan};
4use std::sync::Arc;
5use thiserror::Error;
6
7/// A single validation issue (used with #[related] for multiple error reporting)
8#[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    // ========================================================================
29    // Configuration Errors
30    // ========================================================================
31    #[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    /// TOML parse error with source code context for precise error location display.
75    #[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    /// Configuration validation failed with one or more issues.
100    /// Uses #[related] to display all validation issues together.
101    #[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    /// Backward compatibility for ConfigNotFound with custom message/help
113    #[error("{message}")]
114    #[diagnostic(help("{help}"))]
115    ConfigNotFound { message: String, help: String },
116
117    /// Generic config error for cases not covered by specific variants
118    #[error("Configuration error: {0}")]
119    #[diagnostic(code(fnox::config::error))]
120    Config(String),
121
122    // ========================================================================
123    // Profile Errors
124    // ========================================================================
125
126    // ========================================================================
127    // Secret Errors
128    // ========================================================================
129    #[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    // ========================================================================
164    // Provider Errors
165    // ========================================================================
166    #[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    /// Provider not configured error with source code context showing where the provider is referenced.
198    #[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    /// Default provider not found error with source code context showing where it was configured.
222    #[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    /// Generic provider error for cases not covered by specific variants
243    #[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    // ========================================================================
343    // Encryption Errors
344    // ========================================================================
345    #[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    // ========================================================================
402    // Editor Errors
403    // ========================================================================
404    #[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    // ========================================================================
417    // Lease Errors
418    // ========================================================================
419    #[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    // ========================================================================
433    // Command Execution Errors
434    // ========================================================================
435    #[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    // ========================================================================
452    // Import Errors
453    // ========================================================================
454    #[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    /// Import parse error with source code context for precise error location display.
499    #[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    // ========================================================================
523    // Sync Errors
524    // ========================================================================
525    #[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    // ========================================================================
548    // Re-encrypt Errors
549    // ========================================================================
550    #[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    // ========================================================================
584    // Input/Output Errors
585    // ========================================================================
586    #[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    // ========================================================================
606    // Generic I/O Errors (fallback)
607    // ========================================================================
608    #[error("I/O error: {0}")]
609    #[diagnostic(code(fnox::io::error))]
610    Io(#[from] std::io::Error),
611
612    // ========================================================================
613    // JSON/YAML Errors
614    // ========================================================================
615    #[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
637// Implement conversions for common error types
638impl 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
662// Keep this for backward compatibility with existing miette::miette!() calls
663// We'll phase these out in Phase 2
664impl From<miette::ErrReport> for FnoxError {
665    fn from(err: miette::ErrReport) -> Self {
666        FnoxError::Config(format!("{}", err))
667    }
668}
669
670impl FnoxError {
671    /// Returns true if this error represents a provider authentication failure.
672    pub fn is_auth_error(&self) -> bool {
673        matches!(self, FnoxError::ProviderAuthFailed { .. })
674    }
675
676    /// Clone a provider error variant (all fields are `String`, so always cloneable).
677    ///
678    /// Returns `None` for non-provider error variants.
679    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    /// Map a batch-level error to a per-secret error, preserving structured variants.
741    ///
742    /// If the error is `ProviderSecretNotFound`, the secret name is replaced with the given name.
743    /// Other provider error variants are cloned as-is. Non-provider errors fall back to
744    /// `ProviderCliFailed` with the given provider name, hint, and URL.
745    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}