Skip to main content

fnox_core/
config.rs

1use crate::env;
2use crate::error::{FnoxError, Result};
3use crate::settings::Settings;
4use crate::source_registry;
5use crate::spanned::SpannedValue;
6use clap::ValueEnum;
7use indexmap::IndexMap;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::fs;
12use std::ops::Range;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15use strum::VariantNames;
16
17/// Default config filename, used as the clap default for `--config`.
18pub const DEFAULT_CONFIG_FILENAME: &str = "fnox.toml";
19
20/// Returns all config filenames in load order (first = lowest priority, last = highest priority).
21///
22/// Order: main configs → profile configs → local configs
23/// Within each group, non-dotfiles come first (lower priority); dotfiles follow (higher priority).
24pub fn all_config_filenames(profile: Option<&str>) -> Vec<String> {
25    let mut files = vec![
26        DEFAULT_CONFIG_FILENAME.to_string(),
27        ".fnox.toml".to_string(),
28    ];
29    if let Some(p) = profile.filter(|p| *p != "default") {
30        files.push(format!("fnox.{p}.toml"));
31        files.push(format!(".fnox.{p}.toml"));
32    }
33    files.push("fnox.local.toml".to_string());
34    files.push(".fnox.local.toml".to_string());
35    files
36}
37
38/// Returns the local override filename for a supported config basename.
39///
40/// Only `fnox.toml` and `.fnox.toml` have corresponding local override files.
41pub fn local_override_filename(path: &Path) -> Option<&'static str> {
42    match path.file_name().and_then(|name| name.to_str()) {
43        Some("fnox.toml") => Some("fnox.local.toml"),
44        Some(".fnox.toml") => Some(".fnox.local.toml"),
45        _ => None,
46    }
47}
48
49/// Find the most appropriate existing config file in `dir` for writing.
50///
51/// When a non-default profile is active, prefers the profile-specific file
52/// (e.g. `fnox.staging.toml`) if it exists, so secrets stay scoped to that
53/// profile. Otherwise falls back to the lowest-priority existing file.
54/// If no config files exist yet, returns `fnox.toml`.
55pub fn find_local_config(dir: &Path, profile: Option<&str>) -> PathBuf {
56    // If a non-default profile is specified, prefer its config file first
57    if let Some(p) = profile.filter(|p| *p != "default") {
58        for name in [format!("fnox.{p}.toml"), format!(".fnox.{p}.toml")] {
59            let path = dir.join(&name);
60            if path.exists() {
61                return path;
62            }
63        }
64    }
65
66    // Fall back to lowest-priority existing base file.
67    // When a profile is active, exclude local files (fnox.local.toml, .fnox.local.toml)
68    // to avoid silently routing profile-scoped secrets into a gitignored local-override file.
69    let is_profiled = profile.is_some_and(|p| p != "default");
70    for name in &["fnox.toml", ".fnox.toml"] {
71        let path = dir.join(name);
72        if path.exists() {
73            return path;
74        }
75    }
76    if !is_profiled {
77        for name in &["fnox.local.toml", ".fnox.local.toml"] {
78            let path = dir.join(name);
79            if path.exists() {
80                return path;
81            }
82        }
83    }
84    dir.join(DEFAULT_CONFIG_FILENAME)
85}
86
87// Re-export ProviderConfig from providers module
88pub use crate::providers::ProviderConfig;
89
90#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
91#[serde(deny_unknown_fields)]
92pub struct Config {
93    /// Import paths to other config files
94    #[serde(default, skip_serializing_if = "Vec::is_empty")]
95    pub import: Vec<String>,
96
97    /// Root configuration - stops recursion at this level
98    #[serde(default, skip_serializing_if = "is_false")]
99    pub root: bool,
100
101    /// Lease backend configurations (for default profile)
102    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
103    pub leases: IndexMap<String, crate::lease_backends::LeaseBackendConfig>,
104
105    /// Provider configurations (for default profile)
106    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
107    pub providers: IndexMap<String, ProviderConfig>,
108
109    /// Default provider name for default profile
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    default_provider: Option<SpannedValue<String>>,
112
113    /// Default profile secrets (top level)
114    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
115    pub secrets: IndexMap<String, SecretConfig>,
116
117    /// Named profiles
118    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
119    pub profiles: IndexMap<String, ProfileConfig>,
120
121    /// Age encryption key file path (optional, can also be set via env var or CLI flag)
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub age_key_file: Option<PathBuf>,
124
125    /// Default if_missing behavior for all secrets in this config
126    #[serde(skip_serializing_if = "Option::is_none")]
127    pub if_missing: Option<IfMissing>,
128
129    /// Whether to prompt for authentication when provider auth fails (default: true in TTY)
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub prompt_auth: Option<bool>,
132
133    /// MCP server configuration
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub mcp: Option<McpConfig>,
136
137    /// Track which config file each provider came from (not serialized)
138    #[serde(skip)]
139    pub provider_sources: HashMap<String, PathBuf>,
140
141    /// Track which config file each secret came from (not serialized)
142    #[serde(skip)]
143    pub secret_sources: HashMap<String, PathBuf>,
144
145    /// Track which config file the default_provider came from (not serialized)
146    #[serde(skip)]
147    pub default_provider_source: Option<PathBuf>,
148
149    /// The project root directory — the nearest directory to cwd that contains
150    /// a config file. Used for scoping the lease ledger per-project.
151    #[serde(skip)]
152    pub project_dir: Option<PathBuf>,
153}
154
155/// Cached sync data for a secret (provider + encrypted value)
156#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
157pub struct SyncConfig {
158    pub provider: String,
159    pub value: String,
160}
161
162/// Configuration for a single secret
163#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
164#[serde(deny_unknown_fields)]
165pub struct SecretConfig {
166    /// Description of the secret
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub description: Option<String>,
169
170    /// What to do if the secret is missing (error, warn, or ignore)
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub if_missing: Option<IfMissing>,
173
174    /// Default value to use if provider fails or secret is not found
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub default: Option<String>,
177
178    /// Provider to fetch from (age, aws-kms, 1password, aws, etc.)
179    #[serde(skip_serializing_if = "Option::is_none")]
180    provider: Option<SpannedValue<String>>,
181
182    /// Value for the provider (secret name, encrypted blob, etc.)
183    #[serde(skip_serializing_if = "Option::is_none")]
184    value: Option<SpannedValue<String>>,
185
186    /// Whether to inject this secret into env vars (default: true)
187    /// When false, the secret is only accessible via `fnox get`
188    #[serde(default = "default_true", skip_serializing_if = "is_true")]
189    pub env: bool,
190
191    /// Write secret to a temporary file and set env var to the file path instead of the secret value
192    #[serde(default, skip_serializing_if = "is_false")]
193    pub as_file: bool,
194    /// JSON path to extract from the secret value (supports dot notation: "nested.key")
195    /// When set, the secret value is parsed as JSON and the specified path is extracted.
196    #[serde(skip_serializing_if = "Option::is_none")]
197    pub json_path: Option<String>,
198
199    /// 1-indexed line number to extract from the secret value.
200    /// When set, the secret value is split on newlines and the Nth line is returned.
201    /// Useful for providers whose entries pack multiple related values into a
202    /// single secret (e.g. one value per line). Mutually exclusive with `json_path`.
203    #[serde(skip_serializing_if = "Option::is_none")]
204    #[schemars(range(min = 1))]
205    pub line: Option<usize>,
206
207    /// Cached sync data (provider + encrypted value from `fnox sync`)
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub sync: Option<SyncConfig>,
210
211    /// Path to the config file where this secret was defined (not serialized)
212    #[serde(skip)]
213    pub source_path: Option<PathBuf>,
214
215    /// Whether this secret was loaded from a [profiles.X.secrets] section (not serialized).
216    /// When false, the secret was loaded from a root-level [secrets] section.
217    #[serde(skip)]
218    pub source_is_profile: bool,
219}
220
221/// Configuration for a profile
222#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
223#[serde(deny_unknown_fields)]
224pub struct ProfileConfig {
225    /// Lease backend configurations for this profile
226    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
227    pub leases: IndexMap<String, crate::lease_backends::LeaseBackendConfig>,
228
229    /// Provider configurations for this profile
230    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
231    pub providers: IndexMap<String, ProviderConfig>,
232
233    /// Default provider name for this profile
234    #[serde(default, skip_serializing_if = "Option::is_none")]
235    default_provider: Option<SpannedValue<String>>,
236
237    /// Secrets for this profile
238    #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
239    pub secrets: IndexMap<String, SecretConfig>,
240
241    /// Track which config file each provider came from (not serialized)
242    #[serde(skip)]
243    pub provider_sources: HashMap<String, PathBuf>,
244
245    /// Track which config file each secret came from (not serialized)
246    #[serde(skip)]
247    pub secret_sources: HashMap<String, PathBuf>,
248
249    /// Track which config file the default_provider came from (not serialized)
250    #[serde(skip)]
251    pub default_provider_source: Option<PathBuf>,
252}
253
254/// Available MCP tools
255#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
256#[serde(rename_all = "snake_case")]
257pub enum McpTool {
258    GetSecret,
259    Exec,
260}
261
262impl McpTool {
263    /// Returns the tool name as it appears in MCP protocol
264    pub fn tool_name(&self) -> &'static str {
265        match self {
266            McpTool::GetSecret => "get_secret",
267            McpTool::Exec => "exec",
268        }
269    }
270}
271
272/// MCP server configuration
273#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
274#[serde(deny_unknown_fields)]
275#[derive(Default)]
276pub struct McpConfig {
277    /// Which MCP tools to expose (default: ["get_secret", "exec"])
278    #[serde(default, skip_serializing_if = "Option::is_none", rename = "tools")]
279    tools_raw: Option<Vec<McpTool>>,
280
281    /// Timeout in seconds for exec tool subprocess (default: 300, minimum: 1)
282    #[serde(skip_serializing_if = "Option::is_none")]
283    #[schemars(range(min = 1))]
284    pub exec_timeout_secs: Option<u64>,
285
286    /// Whether to redact secret values from exec tool output (default: true).
287    /// When enabled, resolved secret values are replaced with [REDACTED] in
288    /// stdout/stderr before returning to the agent.
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub redact_output: Option<bool>,
291
292    /// Optional allowlist of secret names visible to the MCP server.
293    /// When set, only these secrets are resolved and available via get_secret/exec.
294    /// When None, all profile secrets are available.
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub secrets: Option<Vec<String>>,
297}
298
299impl McpConfig {
300    fn default_tools() -> Vec<McpTool> {
301        vec![McpTool::GetSecret, McpTool::Exec]
302    }
303
304    /// Whether `tools` was explicitly set in the config file
305    pub fn tools_explicitly_set(&self) -> bool {
306        self.tools_raw.is_some()
307    }
308
309    /// Returns the effective tools list (default if not explicitly set)
310    pub fn tools(&self) -> Vec<McpTool> {
311        self.tools_raw.clone().unwrap_or_else(Self::default_tools)
312    }
313
314    /// Set the tools list explicitly
315    pub fn set_tools(&mut self, tools: Vec<McpTool>) {
316        self.tools_raw = Some(tools);
317    }
318
319    /// Whether exec output redaction is enabled (default: true)
320    pub fn redact_output(&self) -> bool {
321        self.redact_output.unwrap_or(true)
322    }
323
324    /// Filter a secrets map to only include allowed secrets.
325    /// Returns the map unchanged if no allowlist is set.
326    pub fn filter_secrets(
327        &self,
328        secrets: IndexMap<String, SecretConfig>,
329    ) -> IndexMap<String, SecretConfig> {
330        match &self.secrets {
331            None => secrets,
332            Some(allowlist) => {
333                let allowed: std::collections::HashSet<&str> =
334                    allowlist.iter().map(|s| s.as_str()).collect();
335                secrets
336                    .into_iter()
337                    .filter(|(k, _)| allowed.contains(k.as_str()))
338                    .collect()
339            }
340        }
341    }
342}
343
344#[derive(
345    Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, ValueEnum, VariantNames,
346)]
347#[serde(rename_all = "lowercase")]
348pub enum IfMissing {
349    Error,
350    Warn,
351    Ignore,
352}
353
354impl Config {
355    /// Load configuration using the appropriate strategy
356    pub fn load_smart<P: AsRef<Path>>(path: P) -> Result<Self> {
357        let path_ref = path.as_ref();
358
359        // If the path is one of the default config filenames, use recursive loading
360        let default_filenames = all_config_filenames(None);
361        if default_filenames.iter().any(|f| path_ref == Path::new(f)) {
362            Self::load_with_recursion(path_ref)
363        } else {
364            // For explicit paths, resolve relative paths against current directory first
365            let resolved_path = if path_ref.is_relative() {
366                env::current_dir()
367                    .map_err(|e| {
368                        FnoxError::Config(format!("Failed to get current directory: {}", e))
369                    })?
370                    .join(path_ref)
371            } else {
372                path_ref.to_path_buf()
373            };
374            // For explicit paths, use direct loading
375            Self::load(resolved_path)
376        }
377    }
378
379    /// Load configuration from a file
380    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
381        use miette::{NamedSource, SourceSpan};
382
383        let path = path.as_ref();
384        let content = fs::read_to_string(path).map_err(|source| FnoxError::ConfigReadFailed {
385            path: path.to_path_buf(),
386            source,
387        })?;
388
389        // Register the source for error reporting
390        source_registry::register(path, content.clone());
391
392        let mut config: Config = toml_edit::de::from_str(&content).map_err(|e| {
393            // Try to create a source-aware error with span highlighting
394            if let Some(span) = e.span() {
395                FnoxError::ConfigParseErrorWithSource {
396                    message: e.message().to_string(),
397                    src: Arc::new(NamedSource::new(
398                        path.display().to_string(),
399                        Arc::new(content),
400                    )),
401                    span: SourceSpan::new(span.start.into(), span.end - span.start),
402                }
403            } else {
404                // Fall back to the basic error if no span available
405                FnoxError::ConfigParseError { source: e }
406            }
407        })?;
408
409        // Set source paths for all secrets and providers
410        config.set_source_paths(path);
411
412        Ok(config)
413    }
414
415    /// Load configuration with recursive directory search and merging
416    fn load_with_recursion<P: AsRef<Path>>(_start_path: P) -> Result<Self> {
417        // Start from current working directory and search upwards
418        let current_dir = env::current_dir()
419            .map_err(|e| FnoxError::Config(format!("Failed to get current directory: {}", e)))?;
420
421        match Self::load_recursive(&current_dir, false) {
422            Ok((_config, found)) if !found => {
423                // No config file was found anywhere in the directory tree
424                Err(FnoxError::ConfigNotFound {
425                    message: format!(
426                        "No configuration file found in {} or any parent directory",
427                        current_dir.display()
428                    ),
429                    help: "Run 'fnox init' to create a configuration file".to_string(),
430                })
431            }
432            Ok((mut config, _)) => {
433                // Find the nearest directory to cwd that contains a config file.
434                // This is the project root used for scoping the lease ledger.
435                config.project_dir = Self::find_project_dir(&current_dir);
436                Ok(config)
437            }
438            Err(e) => Err(e),
439        }
440    }
441
442    /// Recursively search for fnox.toml files and merge them
443    /// Returns (config, found_any) where found_any indicates if any config file was found
444    fn load_recursive(dir: &Path, found_any: bool) -> Result<(Self, bool)> {
445        // Get current profile from Settings (respects: CLI flag > Env var > Default)
446        let profile = crate::settings::Settings::get().profile.clone();
447        let filenames = all_config_filenames(Some(&profile));
448
449        // Load all existing config files in order (later files override earlier ones)
450        let mut config = Self::new();
451        let mut found = found_any;
452
453        for filename in &filenames {
454            let path = dir.join(filename);
455            if path.exists() {
456                let file_config = Self::load(&path)?;
457                config = Self::merge_configs(config, file_config)?;
458                found = true;
459            }
460        }
461
462        // If this config marks root, stop recursion but still load global config
463        if config.root {
464            // Load imports if any
465            for import_path in &config.import.clone() {
466                let import_config = Self::load_import(import_path, dir)?;
467                config = Self::merge_configs(import_config, config)?;
468            }
469            // Load global config as the base even for root configs
470            let (global_config, global_found) = Self::load_global()?;
471            if global_found {
472                config = Self::merge_configs(global_config, config)?;
473                found = true;
474            }
475            return Ok((config, found));
476        }
477
478        // Load imports first (they get overridden by local config)
479        for import_path in &config.import.clone() {
480            let import_config = Self::load_import(import_path, dir)?;
481            config = Self::merge_configs(import_config, config)?;
482        }
483
484        // If we have a parent directory, recurse up and merge
485        if let Some(parent_dir) = dir.parent() {
486            let (parent_config, parent_found) = Self::load_recursive(parent_dir, found)?;
487            config = Self::merge_configs(parent_config, config)?;
488            found = found || parent_found;
489        } else {
490            // At the filesystem root, try to load global config as base
491            let (global_config, global_found) = Self::load_global()?;
492            if global_found {
493                config = Self::merge_configs(global_config, config)?;
494                found = true;
495            }
496        }
497
498        Ok((config, found))
499    }
500
501    /// Find the nearest directory to `start` that contains a config file.
502    /// Walks upward from `start` and returns the first match.
503    fn find_project_dir(start: &Path) -> Option<PathBuf> {
504        let profile = crate::settings::Settings::get().profile.clone();
505        let filenames = all_config_filenames(Some(&profile));
506        let mut dir = Some(start);
507        while let Some(d) = dir {
508            for filename in &filenames {
509                if d.join(filename).exists() {
510                    return Some(d.to_path_buf());
511                }
512            }
513            dir = d.parent();
514        }
515        None
516    }
517
518    /// Get the path to the global config file
519    pub fn global_config_path() -> PathBuf {
520        env::FNOX_CONFIG_DIR.join("config.toml")
521    }
522
523    /// Load global configuration from FNOX_CONFIG_DIR/config.toml
524    /// This is the lowest priority config, overridden by all project-level configs
525    fn load_global() -> Result<(Self, bool)> {
526        let global_config_path = Self::global_config_path();
527
528        if global_config_path.exists() {
529            tracing::debug!(
530                "Loading global config from {}",
531                global_config_path.display()
532            );
533            let config = Self::load(&global_config_path)?;
534            Ok((config, true))
535        } else {
536            Ok((Self::new(), false))
537        }
538    }
539
540    /// Load an imported config file
541    fn load_import(import_path: &str, base_dir: &Path) -> Result<Self> {
542        let path = PathBuf::from(import_path);
543
544        // Handle relative paths - they're relative to the base config's directory
545        let absolute_path = if path.is_absolute() {
546            path
547        } else {
548            base_dir.join(path)
549        };
550
551        if !absolute_path.exists() {
552            return Err(FnoxError::Config(format!(
553                "Import file not found: {}",
554                absolute_path.display()
555            )));
556        }
557
558        Self::load(&absolute_path)
559    }
560
561    /// Merge two configs, with second config taking precedence
562    fn merge_configs(base: Config, overlay: Config) -> Result<Config> {
563        let mut merged = base;
564
565        // Merge imports (overlay takes precedence, but keep unique paths)
566        for import_path in overlay.import {
567            if !merged.import.contains(&import_path) {
568                merged.import.push(import_path);
569            }
570        }
571
572        // root flag: if either is true, result is true
573        merged.root = merged.root || overlay.root;
574
575        // Merge age_key_file (overlay takes precedence)
576        if overlay.age_key_file.is_some() {
577            merged.age_key_file = overlay.age_key_file;
578        }
579
580        // Merge if_missing (overlay takes precedence)
581        if overlay.if_missing.is_some() {
582            merged.if_missing = overlay.if_missing;
583        }
584
585        // Merge prompt_auth (overlay takes precedence)
586        if overlay.prompt_auth.is_some() {
587            merged.prompt_auth = overlay.prompt_auth;
588        }
589
590        // Merge mcp (overlay takes precedence, field-by-field to avoid
591        // silently re-enabling tools when overlay only sets exec_timeout_secs)
592        if let Some(overlay_mcp) = overlay.mcp {
593            let base_mcp = merged.mcp.get_or_insert_with(McpConfig::default);
594            if overlay_mcp.tools_explicitly_set() {
595                base_mcp.set_tools(overlay_mcp.tools());
596            }
597            if overlay_mcp.exec_timeout_secs.is_some() {
598                base_mcp.exec_timeout_secs = overlay_mcp.exec_timeout_secs;
599            }
600            if overlay_mcp.redact_output.is_some() {
601                base_mcp.redact_output = overlay_mcp.redact_output;
602            }
603            // Replace entirely — a partial overlay should not silently
604            // re-expose secrets that the base config restricted.
605            if overlay_mcp.secrets.is_some() {
606                base_mcp.secrets = overlay_mcp.secrets;
607            }
608        }
609
610        // Merge default_provider and its source (overlay takes precedence)
611        if overlay.default_provider.is_some() {
612            merged.default_provider = overlay.default_provider;
613            merged.default_provider_source = overlay.default_provider_source;
614        }
615
616        // Merge lease backends (overlay takes precedence)
617        for (name, lease) in overlay.leases {
618            merged.leases.insert(name, lease);
619        }
620
621        // Merge providers (overlay takes precedence)
622        for (name, provider) in overlay.providers {
623            merged.providers.insert(name, provider);
624        }
625
626        // Merge provider sources (overlay takes precedence)
627        for (name, source) in overlay.provider_sources {
628            merged.provider_sources.insert(name, source);
629        }
630
631        // Merge secrets (overlay takes precedence)
632        for (name, secret) in overlay.secrets {
633            merged.secrets.insert(name, secret);
634        }
635
636        // Merge secret sources (overlay takes precedence)
637        for (name, source) in overlay.secret_sources {
638            merged.secret_sources.insert(name, source);
639        }
640
641        // Merge profiles (overlay takes precedence)
642        for (name, profile) in overlay.profiles {
643            if let Some(existing_profile) = merged.profiles.get_mut(&name) {
644                // Merge existing profile
645                for (lease_name, lease) in profile.leases {
646                    existing_profile.leases.insert(lease_name, lease);
647                }
648                for (provider_name, provider) in profile.providers {
649                    existing_profile.providers.insert(provider_name, provider);
650                }
651                for (provider_name, source) in &profile.provider_sources {
652                    existing_profile
653                        .provider_sources
654                        .insert(provider_name.clone(), source.clone());
655                }
656                for (secret_name, secret) in profile.secrets {
657                    existing_profile.secrets.insert(secret_name, secret);
658                }
659                for (secret_name, source) in &profile.secret_sources {
660                    existing_profile
661                        .secret_sources
662                        .insert(secret_name.clone(), source.clone());
663                }
664                // Merge default_provider and its source (overlay takes precedence)
665                if profile.default_provider.is_some() {
666                    existing_profile.default_provider = profile.default_provider;
667                    existing_profile.default_provider_source = profile.default_provider_source;
668                }
669            } else {
670                merged.profiles.insert(name, profile);
671            }
672        }
673
674        Ok(merged)
675    }
676
677    /// Save configuration to a file
678    /// Uses toml_edit to preserve insertion order from IndexMap
679    /// and format secrets as inline tables
680    pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
681        // Clone and clean up empty profiles before saving
682        let mut clean_config = self.clone();
683        clean_config
684            .profiles
685            .retain(|_, profile| !profile.is_empty());
686
687        // First serialize with to_string_pretty to get proper structure
688        let pretty_string = toml_edit::ser::to_string_pretty(&clean_config)?;
689
690        // Parse it back as a document so we can modify it
691        let mut doc = pretty_string
692            .parse::<toml_edit::DocumentMut>()
693            .map_err(|e| FnoxError::Config(format!("Failed to parse TOML: {}", e)))?;
694
695        // Convert secrets to inline tables
696        Self::convert_secrets_to_inline(&mut doc)?;
697
698        fs::write(path.as_ref(), doc.to_string()).map_err(|source| {
699            FnoxError::ConfigWriteFailed {
700                path: path.as_ref().to_path_buf(),
701                source,
702            }
703        })?;
704        Ok(())
705    }
706
707    /// Convert all tables in [secrets] and [profiles.*.secrets] to inline tables
708    fn convert_secrets_to_inline(doc: &mut toml_edit::DocumentMut) -> Result<()> {
709        use toml_edit::{InlineTable, Item};
710
711        // Convert top-level [secrets]
712        if let Some(secrets_item) = doc.get_mut("secrets")
713            && let Some(secrets_table) = secrets_item.as_table_mut()
714        {
715            let keys: Vec<String> = secrets_table.iter().map(|(k, _)| k.to_string()).collect();
716            for key in keys {
717                if let Some(item) = secrets_table.get_mut(&key)
718                    && let Some(table) = item.as_table()
719                {
720                    let mut inline = InlineTable::new();
721                    for (k, v) in table.iter() {
722                        if let Some(value) = v.as_value() {
723                            inline.insert(k, value.clone());
724                        }
725                    }
726                    inline.fmt();
727                    *item = Item::Value(toml_edit::Value::InlineTable(inline));
728                }
729            }
730        }
731
732        // Convert [profiles.*.secrets]
733        if let Some(profiles_item) = doc.get_mut("profiles")
734            && let Some(profiles_table) = profiles_item.as_table_mut()
735        {
736            let profile_names: Vec<String> =
737                profiles_table.iter().map(|(k, _)| k.to_string()).collect();
738            for profile_name in profile_names {
739                if let Some(profile_item) = profiles_table.get_mut(&profile_name)
740                    && let Some(profile_table) = profile_item.as_table_mut()
741                    && let Some(secrets_item) = profile_table.get_mut("secrets")
742                    && let Some(secrets_table) = secrets_item.as_table_mut()
743                {
744                    let keys: Vec<String> =
745                        secrets_table.iter().map(|(k, _)| k.to_string()).collect();
746                    for key in keys {
747                        if let Some(item) = secrets_table.get_mut(&key)
748                            && let Some(table) = item.as_table()
749                        {
750                            let mut inline = InlineTable::new();
751                            for (k, v) in table.iter() {
752                                if let Some(value) = v.as_value() {
753                                    inline.insert(k, value.clone());
754                                }
755                            }
756                            inline.fmt();
757                            *item = Item::Value(toml_edit::Value::InlineTable(inline));
758                        }
759                    }
760                }
761            }
762        }
763
764        Ok(())
765    }
766
767    /// Save a single secret update back to its source file
768    /// Always saves to the default_target (local config file), creating a local
769    /// override if the secret exists in a parent config. This aligns with the
770    /// hierarchical config model where child configs override parent configs.
771    ///
772    /// This method preserves comments and formatting in the TOML file by
773    /// directly manipulating the document AST rather than re-serializing.
774    pub fn save_secret_to_source(
775        &self,
776        secret_name: &str,
777        secret_config: &SecretConfig,
778        profile: &str,
779        default_target: &Path,
780    ) -> Result<()> {
781        use toml_edit::{DocumentMut, Item, Value};
782
783        let target_file = default_target.to_path_buf();
784
785        // Load existing document or create new one (preserves comments)
786        let mut doc = if target_file.exists() {
787            let content =
788                fs::read_to_string(&target_file).map_err(|source| FnoxError::ConfigReadFailed {
789                    path: target_file.clone(),
790                    source,
791                })?;
792            content.parse::<DocumentMut>().map_err(|e| {
793                FnoxError::Config(format!(
794                    "Failed to parse TOML in {}: {}",
795                    target_file.display(),
796                    e
797                ))
798            })?
799        } else {
800            DocumentMut::new()
801        };
802
803        // Get or create the secrets table
804        let secrets_table = if profile == "default" {
805            if doc.get("secrets").is_none() {
806                doc["secrets"] = Item::Table(toml_edit::Table::new());
807            }
808            doc["secrets"].as_table_mut().unwrap()
809        } else {
810            if doc.get("profiles").is_none() {
811                doc["profiles"] = Item::Table(toml_edit::Table::new());
812            }
813            let profiles = doc["profiles"].as_table_mut().unwrap();
814            if profiles.get(profile).is_none() {
815                profiles[profile] = Item::Table(toml_edit::Table::new());
816            }
817            let profile_table = profiles[profile].as_table_mut().unwrap();
818            if profile_table.get("secrets").is_none() {
819                profile_table["secrets"] = Item::Table(toml_edit::Table::new());
820            }
821            profile_table["secrets"].as_table_mut().unwrap()
822        };
823
824        if let Some(item) = secrets_table.get_mut(secret_name) {
825            secret_config.update_toml_item(item);
826            if let Some(mut key) = secrets_table.key_mut(secret_name) {
827                key.leaf_decor_mut().set_suffix("");
828            }
829        } else {
830            secrets_table[secret_name] =
831                Item::Value(Value::InlineTable(secret_config.to_inline_table()));
832
833            // Remove trailing space from key to match format: KEY= { ... } instead of KEY = { ... }
834            if let Some(mut key) = secrets_table.key_mut(secret_name) {
835                key.leaf_decor_mut().set_suffix("");
836            }
837        }
838
839        // Write back (preserves all comments and formatting)
840        fs::write(&target_file, doc.to_string()).map_err(|source| {
841            FnoxError::ConfigWriteFailed {
842                path: target_file,
843                source,
844            }
845        })?;
846
847        Ok(())
848    }
849
850    /// Remove a single secret from a config file, preserving comments and formatting.
851    ///
852    /// This method directly manipulates the TOML document AST rather than
853    /// re-serializing, so all comments, whitespace, and formatting are preserved.
854    pub fn remove_secret_from_source(
855        secret_name: &str,
856        profile: &str,
857        target_file: &Path,
858    ) -> Result<bool> {
859        use toml_edit::DocumentMut;
860
861        let content =
862            fs::read_to_string(target_file).map_err(|source| FnoxError::ConfigReadFailed {
863                path: target_file.to_path_buf(),
864                source,
865            })?;
866        let mut doc = content.parse::<DocumentMut>().map_err(|e| {
867            FnoxError::Config(format!(
868                "Failed to parse TOML in {}: {}",
869                target_file.display(),
870                e
871            ))
872        })?;
873
874        // Navigate to the secrets table
875        let removed = if profile == "default" {
876            doc.get_mut("secrets")
877                .and_then(|s| s.as_table_mut())
878                .map(|t| t.remove(secret_name).is_some())
879                .unwrap_or(false)
880        } else {
881            doc.get_mut("profiles")
882                .and_then(|p| p.as_table_mut())
883                .and_then(|p| p.get_mut(profile))
884                .and_then(|p| p.as_table_mut())
885                .and_then(|p| p.get_mut("secrets"))
886                .and_then(|s| s.as_table_mut())
887                .map(|t| t.remove(secret_name).is_some())
888                .unwrap_or(false)
889        };
890
891        if removed {
892            fs::write(target_file, doc.to_string()).map_err(|source| {
893                FnoxError::ConfigWriteFailed {
894                    path: target_file.to_path_buf(),
895                    source,
896                }
897            })?;
898        }
899
900        Ok(removed)
901    }
902
903    /// Save multiple secrets to a config file, preserving comments and formatting.
904    ///
905    /// This is the batch equivalent of `save_secret_to_source`, used by `fnox import`.
906    pub fn save_secrets_to_source(
907        secrets: &IndexMap<String, SecretConfig>,
908        profile: &str,
909        target_file: &Path,
910    ) -> Result<()> {
911        use toml_edit::{DocumentMut, Item, Value};
912
913        // Load existing document or create new one (preserves comments)
914        let mut doc = if target_file.exists() {
915            let content =
916                fs::read_to_string(target_file).map_err(|source| FnoxError::ConfigReadFailed {
917                    path: target_file.to_path_buf(),
918                    source,
919                })?;
920            content.parse::<DocumentMut>().map_err(|e| {
921                FnoxError::Config(format!(
922                    "Failed to parse TOML in {}: {}",
923                    target_file.display(),
924                    e
925                ))
926            })?
927        } else {
928            DocumentMut::new()
929        };
930
931        // Get or create the secrets table
932        let secrets_table = if profile == "default" {
933            if doc.get("secrets").is_none() {
934                doc["secrets"] = Item::Table(toml_edit::Table::new());
935            }
936            doc["secrets"].as_table_mut().unwrap()
937        } else {
938            if doc.get("profiles").is_none() {
939                doc["profiles"] = Item::Table(toml_edit::Table::new());
940            }
941            let profiles = doc["profiles"].as_table_mut().unwrap();
942            if profiles.get(profile).is_none() {
943                profiles[profile] = Item::Table(toml_edit::Table::new());
944            }
945            let profile_table = profiles[profile].as_table_mut().unwrap();
946            if profile_table.get("secrets").is_none() {
947                profile_table["secrets"] = Item::Table(toml_edit::Table::new());
948            }
949            profile_table["secrets"].as_table_mut().unwrap()
950        };
951
952        // Insert/update each secret, preserving existing inline-vs-table style.
953        for (name, config) in secrets {
954            let name_str = name.as_str();
955
956            // Update existing values in-place to preserve decor/comments on the entry
957            if let Some(item) = secrets_table.get_mut(name_str) {
958                config.update_toml_item(item);
959                if let Some(mut key) = secrets_table.key_mut(name_str) {
960                    key.leaf_decor_mut().set_suffix("");
961                }
962            } else {
963                secrets_table[name_str] = Item::Value(Value::InlineTable(config.to_inline_table()));
964
965                if let Some(mut key) = secrets_table.key_mut(name_str) {
966                    key.leaf_decor_mut().set_suffix("");
967                }
968            }
969        }
970
971        // Write back (preserves all comments and formatting)
972        fs::write(target_file, doc.to_string()).map_err(|source| FnoxError::ConfigWriteFailed {
973            path: target_file.to_path_buf(),
974            source,
975        })?;
976
977        Ok(())
978    }
979
980    /// Create a new default configuration
981    pub fn new() -> Self {
982        Self {
983            import: Vec::new(),
984            root: false,
985            leases: IndexMap::new(),
986            providers: IndexMap::new(),
987            default_provider: None,
988            secrets: IndexMap::new(),
989            profiles: IndexMap::new(),
990            age_key_file: None,
991            if_missing: None,
992            prompt_auth: None,
993            mcp: None,
994            provider_sources: HashMap::new(),
995            secret_sources: HashMap::new(),
996            default_provider_source: None,
997            project_dir: None,
998        }
999    }
1000
1001    /// Get the profile to use (from flag or env var, defaulting to "default")
1002    pub fn get_profile(profile_flag: Option<&str>) -> String {
1003        profile_flag
1004            .map(String::from)
1005            .or_else(|| (*env::FNOX_PROFILE).clone())
1006            .unwrap_or_else(|| "default".to_string())
1007    }
1008
1009    /// Determine if we should prompt for authentication when provider auth fails.
1010    /// Priority: env var > config > default (true)
1011    /// Returns true only if prompting is enabled AND we're in a TTY.
1012    pub fn should_prompt_auth(&self) -> bool {
1013        // Check env var first
1014        let enabled = (*env::FNOX_PROMPT_AUTH)
1015            .or(self.prompt_auth)
1016            .unwrap_or(true);
1017
1018        // Only prompt if enabled AND we're in a TTY
1019        enabled && atty::is(atty::Stream::Stdin)
1020    }
1021
1022    /// Get secrets for the default profile (mutable)
1023    pub fn get_default_secrets_mut(&mut self) -> &mut IndexMap<String, SecretConfig> {
1024        &mut self.secrets
1025    }
1026
1027    /// Get secrets for a specific profile (mutable)
1028    pub fn get_profile_secrets_mut(
1029        &mut self,
1030        profile: &str,
1031    ) -> &mut IndexMap<String, SecretConfig> {
1032        &mut self
1033            .profiles
1034            .entry(profile.to_string())
1035            .or_default()
1036            .secrets
1037    }
1038
1039    /// Get effective secrets (default or profile)
1040    /// For non-default profiles, this merges top-level secrets with profile-specific secrets,
1041    /// with profile secrets taking precedence.
1042    ///
1043    /// Note: If a profile doesn't exist in [profiles], it's treated as "default".
1044    /// This allows fnox.$FNOX_PROFILE.toml files to work without requiring a [profiles] section.
1045    pub fn get_secrets(&self, profile: &str) -> Result<IndexMap<String, SecretConfig>> {
1046        if profile == "default" {
1047            return Ok(self.secrets.clone());
1048        }
1049
1050        let mut secrets = if Settings::get().no_defaults {
1051            // Profile-only mode: do not merge top-level secrets.
1052            IndexMap::new()
1053        } else {
1054            // Start with top-level secrets as base
1055            self.secrets.clone()
1056        };
1057
1058        // Get profile-specific secrets and merge/override (if profile exists)
1059        if let Some(profile_config) = self.profiles.get(profile) {
1060            // Profile-specific secrets override top-level ones
1061            secrets.extend(profile_config.secrets.clone());
1062        }
1063        // If profile doesn't exist in [profiles], that's OK - just use top-level secrets
1064        // This allows fnox.$FNOX_PROFILE.toml to work without requiring [profiles.xxx]
1065        Ok(secrets)
1066    }
1067
1068    /// Look up a single secret by key without cloning the secrets map.
1069    ///
1070    /// Mirrors the precedence used by [`Self::get_secrets`]: profile-specific
1071    /// secrets take precedence, falling back to top-level secrets unless
1072    /// `no_defaults` is set.
1073    pub fn get_secret(&self, profile: &str, key: &str) -> Option<&SecretConfig> {
1074        if profile != "default"
1075            && let Some(profile_config) = self.profiles.get(profile)
1076            && let Some(secret) = profile_config.secrets.get(key)
1077        {
1078            return Some(secret);
1079        }
1080
1081        if profile != "default" && Settings::get().no_defaults {
1082            return None;
1083        }
1084
1085        self.secrets.get(key)
1086    }
1087
1088    /// Get effective secrets (default or profile, mutable)
1089    pub fn get_secrets_mut(&mut self, profile: &str) -> &mut IndexMap<String, SecretConfig> {
1090        if profile == "default" {
1091            self.get_default_secrets_mut()
1092        } else {
1093            self.get_profile_secrets_mut(profile)
1094        }
1095    }
1096
1097    /// Get effective lease backends for a profile
1098    pub fn get_leases(
1099        &self,
1100        profile: &str,
1101    ) -> IndexMap<String, crate::lease_backends::LeaseBackendConfig> {
1102        let mut leases = self.leases.clone();
1103
1104        if profile != "default"
1105            && let Some(profile_config) = self.profiles.get(profile)
1106        {
1107            leases.extend(profile_config.leases.clone());
1108        }
1109
1110        leases
1111    }
1112
1113    /// Get effective providers for a profile
1114    pub fn get_providers(&self, profile: &str) -> IndexMap<String, ProviderConfig> {
1115        let mut providers = self.providers.clone(); // Start with global providers
1116
1117        if profile != "default"
1118            && let Some(profile_config) = self.profiles.get(profile)
1119        {
1120            providers.extend(profile_config.providers.clone());
1121        }
1122
1123        providers
1124    }
1125
1126    /// Get the default provider for a profile
1127    /// Returns the configured default_provider, or auto-selects if there's only one provider
1128    pub fn get_default_provider(&self, profile: &str) -> Result<Option<String>> {
1129        let providers = self.get_providers(profile);
1130
1131        // If no providers configured and this is a root config, return None
1132        if providers.is_empty() && self.root {
1133            return Ok(None);
1134        }
1135
1136        // If no providers configured, that's an error
1137        if providers.is_empty() {
1138            return Err(FnoxError::Config(
1139                "No providers configured. Add at least one provider to fnox.toml".to_string(),
1140            ));
1141        }
1142
1143        // Check for profile-specific default provider
1144        if profile != "default"
1145            && let Some(profile_config) = self.profiles.get(profile)
1146            && let Some(default_provider_name) = profile_config.default_provider()
1147        {
1148            // Validate that the default provider exists
1149            if !providers.contains_key(default_provider_name) {
1150                // Try to get source info for better error reporting
1151                if let Some(source_path) = &profile_config.default_provider_source
1152                    && let (Some(src), Some(span)) = (
1153                        source_registry::get_named_source(source_path),
1154                        profile_config.default_provider_span(),
1155                    )
1156                {
1157                    return Err(FnoxError::DefaultProviderNotFoundWithSource {
1158                        provider: default_provider_name.to_string(),
1159                        profile: profile.to_string(),
1160                        src,
1161                        span: span.into(),
1162                    });
1163                }
1164                return Err(FnoxError::Config(format!(
1165                    "Default provider '{}' not found in profile '{}'",
1166                    default_provider_name, profile
1167                )));
1168            }
1169            return Ok(Some(default_provider_name.to_string()));
1170        }
1171
1172        // Check for global default provider (for default profile or as fallback)
1173        if let Some(default_provider_name) = self.default_provider() {
1174            // Validate that the default provider exists
1175            if !providers.contains_key(default_provider_name) {
1176                // Try to get source info for better error reporting
1177                if let Some(source_path) = &self.default_provider_source
1178                    && let (Some(src), Some(span)) = (
1179                        source_registry::get_named_source(source_path),
1180                        self.default_provider_span(),
1181                    )
1182                {
1183                    return Err(FnoxError::DefaultProviderNotFoundWithSource {
1184                        provider: default_provider_name.to_string(),
1185                        profile: profile.to_string(),
1186                        src,
1187                        span: span.into(),
1188                    });
1189                }
1190                return Err(FnoxError::Config(format!(
1191                    "Default provider '{}' not found in configuration",
1192                    default_provider_name
1193                )));
1194            }
1195            return Ok(Some(default_provider_name.to_string()));
1196        }
1197
1198        // If there's exactly one provider, auto-select it
1199        if providers.len() == 1 {
1200            let provider_name = providers.keys().next().unwrap().clone();
1201            tracing::debug!(
1202                "Auto-selecting provider '{}' as it's the only one configured",
1203                provider_name
1204            );
1205            return Ok(Some(provider_name));
1206        }
1207
1208        // Multiple providers, no default configured
1209        Ok(None)
1210    }
1211
1212    /// Set source paths for all secrets and providers in this config
1213    fn set_source_paths(&mut self, path: &Path) {
1214        // Set source paths for default profile secrets
1215        for (key, secret) in self.secrets.iter_mut() {
1216            secret.source_path = Some(path.to_path_buf());
1217            self.secret_sources.insert(key.clone(), path.to_path_buf());
1218        }
1219
1220        // Set source paths for default profile providers
1221        for (provider_name, _) in self.providers.iter() {
1222            self.provider_sources
1223                .insert(provider_name.clone(), path.to_path_buf());
1224        }
1225
1226        // Set source path for default_provider if set
1227        if self.default_provider().is_some() {
1228            self.default_provider_source = Some(path.to_path_buf());
1229        }
1230
1231        // Set source paths for named profiles
1232        for (_profile_name, profile) in self.profiles.iter_mut() {
1233            for (key, secret) in profile.secrets.iter_mut() {
1234                secret.source_path = Some(path.to_path_buf());
1235                secret.source_is_profile = true;
1236                profile
1237                    .secret_sources
1238                    .insert(key.clone(), path.to_path_buf());
1239            }
1240
1241            for (provider_name, _) in profile.providers.iter() {
1242                profile
1243                    .provider_sources
1244                    .insert(provider_name.clone(), path.to_path_buf());
1245            }
1246
1247            // Set source path for profile's default_provider if set
1248            if profile.default_provider().is_some() {
1249                profile.default_provider_source = Some(path.to_path_buf());
1250            }
1251        }
1252    }
1253
1254    /// Check if a secret has an empty value that should be flagged as a validation issue.
1255    /// Returns a ValidationIssue if the secret has an empty value and is not using plain provider.
1256    fn check_empty_value(
1257        &self,
1258        key: &str,
1259        secret: &SecretConfig,
1260        profile: &str,
1261    ) -> Option<crate::error::ValidationIssue> {
1262        // Early return if value is not an empty string
1263        let Some(value) = secret.value() else {
1264            return None; // No value specified - not an issue
1265        };
1266        if !value.is_empty() {
1267            return None; // Non-empty value - not an issue
1268        }
1269
1270        // At this point, value is an empty string
1271        // Allow empty values for plain provider (empty string is a valid secret value)
1272        if self.is_plain_provider(secret.provider(), profile) {
1273            return None;
1274        }
1275        let message = if profile == "default" {
1276            format!("Secret '{}' has an empty value", key)
1277        } else {
1278            format!(
1279                "Secret '{}' in profile '{}' has an empty value",
1280                key, profile
1281            )
1282        };
1283        Some(crate::error::ValidationIssue::with_help(
1284            message,
1285            "Set a value for this secret or remove it from the configuration",
1286        ))
1287    }
1288
1289    /// Check if a secret uses the plain provider (where empty values are valid).
1290    /// Returns true if the provider is "plain" type.
1291    fn is_plain_provider(&self, secret_provider: Option<&str>, profile: &str) -> bool {
1292        // Get providers for this profile first (needed for auto-selection)
1293        let providers = self.get_providers(profile);
1294
1295        // Determine which provider name to use
1296        let provider_name = secret_provider
1297            .map(String::from)
1298            .or_else(|| {
1299                // Try profile's default_provider first (only for non-default profiles)
1300                if profile != "default" {
1301                    self.profiles
1302                        .get(profile)
1303                        .and_then(|p| p.default_provider().map(|s| s.to_string()))
1304                } else {
1305                    None
1306                }
1307            })
1308            .or_else(|| self.default_provider().map(|s| s.to_string()))
1309            .or_else(|| {
1310                // Auto-select if exactly one provider exists (matching get_default_provider behavior)
1311                if providers.len() == 1 {
1312                    providers.keys().next().cloned()
1313                } else {
1314                    None
1315                }
1316            });
1317
1318        let Some(provider_name) = provider_name else {
1319            return false;
1320        };
1321
1322        // Look up the provider config
1323        providers
1324            .get(&provider_name)
1325            .is_some_and(|p| p.provider_type() == "plain")
1326    }
1327
1328    /// Validate the configuration
1329    /// Collects all validation issues and returns them together using #[related]
1330    pub fn validate(&self) -> Result<()> {
1331        use crate::error::ValidationIssue;
1332
1333        // If root=true and no providers AND no secrets, that's OK (empty config)
1334        if self.root
1335            && self.providers.is_empty()
1336            && self.profiles.is_empty()
1337            && self.secrets.is_empty()
1338        {
1339            return Ok(());
1340        }
1341
1342        let mut issues = Vec::new();
1343
1344        // Check for secrets with empty values (likely a mistake, but allowed for plain provider)
1345        for (key, secret) in &self.secrets {
1346            if let Some(issue) = self.check_empty_value(key, secret, "default") {
1347                issues.push(issue);
1348            }
1349        }
1350
1351        // Check that there's at least one provider if there are any secrets
1352        if self.providers.is_empty() && self.profiles.is_empty() && !self.secrets.is_empty() {
1353            issues.push(ValidationIssue::with_help(
1354                "No providers configured",
1355                "Add at least one provider to fnox.toml",
1356            ));
1357        }
1358
1359        // If default_provider is set, validate it exists
1360        if let Some(default_provider_name) = self.default_provider()
1361            && !self.providers.contains_key(default_provider_name)
1362        {
1363            // Try to get source info for better error reporting
1364            if let Some(source_path) = &self.default_provider_source
1365                && let (Some(src), Some(span)) = (
1366                    source_registry::get_named_source(source_path),
1367                    self.default_provider_span(),
1368                )
1369            {
1370                return Err(FnoxError::DefaultProviderNotFoundWithSource {
1371                    provider: default_provider_name.to_string(),
1372                    profile: "default".to_string(),
1373                    src,
1374                    span: span.into(),
1375                });
1376            }
1377            issues.push(ValidationIssue::with_help(
1378                format!(
1379                    "Default provider '{}' not found in configuration",
1380                    default_provider_name
1381                ),
1382                format!(
1383                    "Add [providers.{}] to your config or remove the default_provider setting",
1384                    default_provider_name
1385                ),
1386            ));
1387        }
1388
1389        // Validate each profile
1390        for (profile_name, profile_config) in &self.profiles {
1391            let providers = self.get_providers(profile_name);
1392
1393            // Check for profile secrets with empty values (likely a mistake, but allowed for plain provider)
1394            for (key, secret) in &profile_config.secrets {
1395                if let Some(issue) = self.check_empty_value(key, secret, profile_name) {
1396                    issues.push(issue);
1397                }
1398            }
1399
1400            // Each profile must have at least one provider (inherited or its own), unless root=true
1401            if providers.is_empty() && !self.root {
1402                issues.push(ValidationIssue::with_help(
1403                    format!("Profile '{}' has no providers configured", profile_name),
1404                    format!(
1405                        "Add [profiles.{}.providers.<name>] or inherit from top-level providers",
1406                        profile_name
1407                    ),
1408                ));
1409            }
1410
1411            // If profile has default_provider set, validate it exists
1412            if let Some(default_provider_name) = profile_config.default_provider()
1413                && !providers.contains_key(default_provider_name)
1414            {
1415                // Try to get source info for better error reporting
1416                if let Some(source_path) = &profile_config.default_provider_source
1417                    && let (Some(src), Some(span)) = (
1418                        source_registry::get_named_source(source_path),
1419                        profile_config.default_provider_span(),
1420                    )
1421                {
1422                    return Err(FnoxError::DefaultProviderNotFoundWithSource {
1423                        provider: default_provider_name.to_string(),
1424                        profile: profile_name.clone(),
1425                        src,
1426                        span: span.into(),
1427                    });
1428                }
1429                issues.push(ValidationIssue::with_help(
1430                    format!(
1431                        "Default provider '{}' not found in profile '{}'",
1432                        default_provider_name, profile_name
1433                    ),
1434                    format!(
1435                        "Add [profiles.{}.providers.{}] or remove the default_provider setting",
1436                        profile_name, default_provider_name
1437                    ),
1438                ));
1439            }
1440        }
1441
1442        if issues.is_empty() {
1443            Ok(())
1444        } else {
1445            Err(FnoxError::ConfigValidationFailed { issues })
1446        }
1447    }
1448
1449    /// Get the default provider name, if set.
1450    pub fn default_provider(&self) -> Option<&str> {
1451        self.default_provider
1452            .as_ref()
1453            .map(|s: &SpannedValue<String>| s.value().as_str())
1454    }
1455
1456    /// Get the default provider's source span (byte range in the config file).
1457    /// Returns None if the default_provider wasn't set or was created programmatically.
1458    pub fn default_provider_span(&self) -> Option<Range<usize>> {
1459        self.default_provider
1460            .as_ref()
1461            .and_then(|s: &SpannedValue<String>| s.span())
1462    }
1463
1464    /// Set the default provider name (without span information).
1465    pub fn set_default_provider(&mut self, provider: Option<String>) {
1466        self.default_provider = provider.map(SpannedValue::without_span);
1467    }
1468}
1469
1470impl Default for Config {
1471    fn default() -> Self {
1472        Self::new()
1473    }
1474}
1475
1476impl SecretConfig {
1477    /// Create a new secret config with just metadata
1478    pub fn new() -> Self {
1479        Self {
1480            description: None,
1481            if_missing: None,
1482            default: None,
1483            provider: None,
1484            value: None,
1485            env: true,
1486            as_file: false,
1487            json_path: None,
1488            line: None,
1489            sync: None,
1490            source_path: None,
1491            source_is_profile: false,
1492        }
1493    }
1494
1495    /// Return a copy that will resolve to the original provider value,
1496    /// skipping post-processing and cached sync values.
1497    pub fn for_raw_resolve(&self) -> Self {
1498        let mut config = self.clone();
1499        config.json_path = None;
1500        config.line = None;
1501        config.sync = None;
1502        config.default = None;
1503        config
1504    }
1505
1506    /// Convert this secret config to a TOML inline table for saving
1507    pub fn to_inline_table(&self) -> toml_edit::InlineTable {
1508        let mut inline = toml_edit::InlineTable::new();
1509
1510        if let Some(provider) = self.provider() {
1511            inline.insert("provider", toml_edit::Value::from(provider));
1512        }
1513        if let Some(value) = self.value() {
1514            inline.insert("value", toml_edit::Value::from(value));
1515        }
1516        if let Some(ref json_path) = self.json_path {
1517            inline.insert("json_path", toml_edit::Value::from(json_path.as_str()));
1518        }
1519        if let Some(line) = self.line {
1520            inline.insert("line", toml_edit::Value::from(line as i64));
1521        }
1522        if let Some(ref description) = self.description {
1523            inline.insert("description", toml_edit::Value::from(description.as_str()));
1524        }
1525        if let Some(ref default) = self.default {
1526            inline.insert("default", toml_edit::Value::from(default.as_str()));
1527        }
1528        if let Some(if_missing) = self.if_missing {
1529            let if_missing_str = match if_missing {
1530                IfMissing::Error => "error",
1531                IfMissing::Warn => "warn",
1532                IfMissing::Ignore => "ignore",
1533            };
1534            inline.insert("if_missing", toml_edit::Value::from(if_missing_str));
1535        }
1536        if !self.env {
1537            inline.insert("env", toml_edit::Value::from(false));
1538        }
1539        if self.as_file {
1540            inline.insert("as_file", toml_edit::Value::from(true));
1541        }
1542        if let Some(ref sync) = self.sync {
1543            let mut sync_table = toml_edit::InlineTable::new();
1544            sync_table.insert("provider", toml_edit::Value::from(sync.provider.as_str()));
1545            sync_table.insert("value", toml_edit::Value::from(sync.value.as_str()));
1546            sync_table.fmt();
1547            inline.insert("sync", toml_edit::Value::InlineTable(sync_table));
1548        }
1549
1550        inline.fmt();
1551        inline
1552    }
1553
1554    /// Write this secret config into an existing TOML table while preserving
1555    /// that table's header/decor and table-style representation.
1556    pub fn write_to_table(&self, table: &mut toml_edit::Table) {
1557        use toml_edit::{Item, Value};
1558
1559        fn set_or_remove(table: &mut toml_edit::Table, key: &str, value: Option<Value>) {
1560            if let Some(value) = value {
1561                table[key] = Item::Value(value);
1562            } else {
1563                table.remove(key);
1564            }
1565        }
1566
1567        set_or_remove(table, "provider", self.provider().map(Value::from));
1568        set_or_remove(table, "value", self.value().map(Value::from));
1569        set_or_remove(
1570            table,
1571            "json_path",
1572            self.json_path.as_deref().map(Value::from),
1573        );
1574        set_or_remove(
1575            table,
1576            "line",
1577            self.line.map(|line| Value::from(line as i64)),
1578        );
1579        set_or_remove(
1580            table,
1581            "description",
1582            self.description.as_deref().map(Value::from),
1583        );
1584        set_or_remove(table, "default", self.default.as_deref().map(Value::from));
1585        set_or_remove(
1586            table,
1587            "if_missing",
1588            self.if_missing.map(|if_missing| {
1589                Value::from(match if_missing {
1590                    IfMissing::Error => "error",
1591                    IfMissing::Warn => "warn",
1592                    IfMissing::Ignore => "ignore",
1593                })
1594            }),
1595        );
1596        set_or_remove(table, "env", (!self.env).then(|| Value::from(false)));
1597        set_or_remove(table, "as_file", self.as_file.then(|| Value::from(true)));
1598        set_or_remove(
1599            table,
1600            "sync",
1601            self.sync.as_ref().map(|sync| {
1602                let mut sync_table = toml_edit::InlineTable::new();
1603                sync_table.insert("provider", Value::from(sync.provider.as_str()));
1604                sync_table.insert("value", Value::from(sync.value.as_str()));
1605                sync_table.fmt();
1606                Value::InlineTable(sync_table)
1607            }),
1608        );
1609    }
1610
1611    /// Update a TOML item with this secret config while preserving the
1612    /// item's existing table-vs-inline-table style.
1613    pub fn update_toml_item(&self, item: &mut toml_edit::Item) {
1614        use toml_edit::{Item, Value};
1615
1616        match item {
1617            Item::Table(table) => self.write_to_table(table),
1618            Item::Value(Value::InlineTable(existing_inline)) => {
1619                *existing_inline = self.to_inline_table();
1620            }
1621            _ => {
1622                *item = Item::Value(Value::InlineTable(self.to_inline_table()));
1623            }
1624        }
1625    }
1626
1627    /// Check if this secret has any value (provider, value, or default)
1628    pub fn has_value(&self) -> bool {
1629        self.provider().is_some() || self.value().is_some() || self.default.is_some()
1630    }
1631
1632    /// Get the provider name, if set.
1633    pub fn provider(&self) -> Option<&str> {
1634        self.provider.as_ref().map(|s| s.value().as_str())
1635    }
1636
1637    /// Get the provider's source span (byte range in the config file).
1638    /// Returns None if the provider wasn't set or was created programmatically.
1639    pub fn provider_span(&self) -> Option<Range<usize>> {
1640        self.provider.as_ref().and_then(|s| s.span())
1641    }
1642
1643    /// Set the provider name (without span information).
1644    pub fn set_provider(&mut self, provider: Option<String>) {
1645        self.provider = provider.map(SpannedValue::without_span);
1646    }
1647
1648    /// Get the value, if set.
1649    pub fn value(&self) -> Option<&str> {
1650        self.value
1651            .as_ref()
1652            .map(|s: &SpannedValue<String>| s.value().as_str())
1653    }
1654
1655    /// Set the value (without span information).
1656    pub fn set_value(&mut self, value: Option<String>) {
1657        self.value = value.map(SpannedValue::without_span);
1658    }
1659}
1660
1661impl ProfileConfig {
1662    /// Create a new profile config
1663    pub fn new() -> Self {
1664        Self {
1665            leases: IndexMap::new(),
1666            providers: IndexMap::new(),
1667            default_provider: None,
1668            secrets: IndexMap::new(),
1669            provider_sources: HashMap::new(),
1670            secret_sources: HashMap::new(),
1671            default_provider_source: None,
1672        }
1673    }
1674
1675    /// Check if the profile is effectively empty (no serializable content)
1676    pub fn is_empty(&self) -> bool {
1677        self.leases.is_empty()
1678            && self.providers.is_empty()
1679            && self.secrets.is_empty()
1680            && self.default_provider().is_none()
1681    }
1682
1683    /// Get the default provider name, if set.
1684    pub fn default_provider(&self) -> Option<&str> {
1685        self.default_provider
1686            .as_ref()
1687            .map(|s: &SpannedValue<String>| s.value().as_str())
1688    }
1689
1690    /// Get the default provider's source span (byte range in the config file).
1691    /// Returns None if the default_provider wasn't set or was created programmatically.
1692    pub fn default_provider_span(&self) -> Option<Range<usize>> {
1693        self.default_provider
1694            .as_ref()
1695            .and_then(|s: &SpannedValue<String>| s.span())
1696    }
1697}
1698
1699impl Default for SecretConfig {
1700    fn default() -> Self {
1701        Self::new()
1702    }
1703}
1704
1705impl Default for ProfileConfig {
1706    fn default() -> Self {
1707        Self::new()
1708    }
1709}
1710
1711fn is_false(value: &bool) -> bool {
1712    !value
1713}
1714
1715fn is_true(value: &bool) -> bool {
1716    *value
1717}
1718
1719fn default_true() -> bool {
1720    true
1721}
1722
1723#[cfg(test)]
1724mod tests {
1725    use super::*;
1726    use std::path::Path;
1727
1728    #[test]
1729    fn test_empty_import_not_serialized() {
1730        let config = Config::new();
1731        let toml = toml_edit::ser::to_string_pretty(&config).unwrap();
1732        assert!(
1733            !toml.contains("import"),
1734            "Empty import should not be serialized"
1735        );
1736    }
1737
1738    #[test]
1739    fn test_non_empty_import_is_serialized() {
1740        let mut config = Config::new();
1741        config.import.push("other.toml".to_string());
1742        let toml = toml_edit::ser::to_string_pretty(&config).unwrap();
1743        assert!(
1744            toml.contains("import"),
1745            "Non-empty import should be serialized"
1746        );
1747        assert!(
1748            toml.contains("other.toml"),
1749            "Import value should be present"
1750        );
1751    }
1752
1753    #[test]
1754    fn test_empty_profiles_not_serialized() {
1755        let config = Config::new();
1756        let toml = toml_edit::ser::to_string_pretty(&config).unwrap();
1757        assert!(
1758            !toml.contains("profiles"),
1759            "Empty profiles should not be serialized"
1760        );
1761    }
1762
1763    #[test]
1764    fn test_non_empty_profiles_is_serialized() {
1765        let mut config = Config::new();
1766
1767        // Add a provider and secret to the prod profile
1768        let mut prod_profile = ProfileConfig::new();
1769        prod_profile.providers.insert(
1770            "plain".to_string(),
1771            ProviderConfig::Plain { auth_command: None },
1772        );
1773        let mut secret = SecretConfig::new();
1774        secret.set_value(Some("test-value".to_string()));
1775        prod_profile
1776            .secrets
1777            .insert("TEST_SECRET".to_string(), secret);
1778
1779        config.profiles.insert("prod".to_string(), prod_profile);
1780        let toml = toml_edit::ser::to_string_pretty(&config).unwrap();
1781
1782        // Print the TOML for debugging
1783        eprintln!("Generated TOML:\n{}", toml);
1784
1785        assert!(
1786            toml.contains("profiles"),
1787            "Non-empty profiles should be serialized"
1788        );
1789        assert!(toml.contains("prod"), "Profile name should be present");
1790
1791        // Check that we don't have a standalone [profiles] header
1792        // We should only have [profiles.prod] style headers
1793        assert!(
1794            !toml.contains("[profiles]\n"),
1795            "Should not have standalone [profiles] header"
1796        );
1797    }
1798
1799    #[test]
1800    fn test_local_override_filename_matches_standard_config_names() {
1801        assert_eq!(
1802            local_override_filename(Path::new("nested/fnox.toml")),
1803            Some("fnox.local.toml")
1804        );
1805        assert_eq!(
1806            local_override_filename(Path::new("nested/.fnox.toml")),
1807            Some(".fnox.local.toml")
1808        );
1809    }
1810
1811    #[test]
1812    fn test_local_override_filename_rejects_non_standard_config_names() {
1813        assert_eq!(
1814            local_override_filename(Path::new("nested/custom.toml")),
1815            None
1816        );
1817        assert_eq!(
1818            local_override_filename(Path::new("nested/fnox.dev.toml")),
1819            None
1820        );
1821    }
1822
1823    #[test]
1824    fn test_empty_profile_not_serialized() {
1825        use std::io::Read;
1826
1827        let mut config = Config::new();
1828        // Add an empty profile (no providers, no secrets)
1829        config
1830            .profiles
1831            .insert("prod".to_string(), ProfileConfig::new());
1832
1833        // Use save() which cleans up empty profiles
1834        let temp_file = std::env::temp_dir().join("fnox_test_empty_profile.toml");
1835        config.save(&temp_file).unwrap();
1836
1837        let mut toml = String::new();
1838        std::fs::File::open(&temp_file)
1839            .unwrap()
1840            .read_to_string(&mut toml)
1841            .unwrap();
1842        std::fs::remove_file(&temp_file).ok();
1843
1844        eprintln!("Generated TOML with empty profile:\n{}", toml);
1845
1846        // Empty profiles should not appear in the output at all
1847        // Because save() cleans them up
1848        assert!(
1849            !toml.contains("[profiles"),
1850            "Empty profile should not be serialized"
1851        );
1852        assert!(
1853            !toml.contains("prod"),
1854            "Empty profile name should not appear"
1855        );
1856    }
1857
1858    #[test]
1859    fn test_no_defaults_profile_only_secrets() {
1860        crate::settings::Settings::reset_for_tests();
1861        crate::settings::Settings::set_cli_snapshot(crate::settings::CliSnapshot {
1862            age_key_file: None,
1863            profile: Some("prod".to_string()),
1864            if_missing: None,
1865            no_defaults: true,
1866        });
1867
1868        let mut config = Config::new();
1869        config
1870            .secrets
1871            .insert("DEFAULT_ONLY".to_string(), SecretConfig::new());
1872
1873        let mut prod_profile = ProfileConfig::new();
1874        prod_profile
1875            .secrets
1876            .insert("PROD_ONLY".to_string(), SecretConfig::new());
1877        config.profiles.insert("prod".to_string(), prod_profile);
1878
1879        let secrets = config.get_secrets("prod").unwrap();
1880        assert!(secrets.contains_key("PROD_ONLY"));
1881        assert!(!secrets.contains_key("DEFAULT_ONLY"));
1882    }
1883
1884    #[test]
1885    fn test_no_defaults_profile_without_section_is_empty() {
1886        crate::settings::Settings::reset_for_tests();
1887        crate::settings::Settings::set_cli_snapshot(crate::settings::CliSnapshot {
1888            age_key_file: None,
1889            profile: Some("prod".to_string()),
1890            if_missing: None,
1891            no_defaults: true,
1892        });
1893
1894        let mut config = Config::new();
1895        config
1896            .secrets
1897            .insert("DEFAULT_ONLY".to_string(), SecretConfig::new());
1898
1899        let secrets = config.get_secrets("prod").unwrap();
1900        assert!(secrets.is_empty());
1901    }
1902
1903    #[test]
1904    fn test_find_local_config_no_files() {
1905        let dir = tempfile::tempdir().unwrap();
1906        let result = super::find_local_config(dir.path(), None);
1907        assert_eq!(result, dir.path().join("fnox.toml"));
1908    }
1909
1910    #[test]
1911    fn test_find_local_config_only_fnox_toml() {
1912        let dir = tempfile::tempdir().unwrap();
1913        std::fs::write(dir.path().join("fnox.toml"), "").unwrap();
1914        let result = super::find_local_config(dir.path(), None);
1915        assert_eq!(result, dir.path().join("fnox.toml"));
1916    }
1917
1918    #[test]
1919    fn test_find_local_config_only_local_toml() {
1920        let dir = tempfile::tempdir().unwrap();
1921        std::fs::write(dir.path().join("fnox.local.toml"), "").unwrap();
1922        let result = super::find_local_config(dir.path(), None);
1923        assert_eq!(result, dir.path().join("fnox.local.toml"));
1924    }
1925
1926    #[test]
1927    fn test_find_local_config_both_exist() {
1928        let dir = tempfile::tempdir().unwrap();
1929        std::fs::write(dir.path().join("fnox.toml"), "").unwrap();
1930        std::fs::write(dir.path().join("fnox.local.toml"), "").unwrap();
1931        let result = super::find_local_config(dir.path(), None);
1932        // Should pick fnox.toml (lowest priority)
1933        assert_eq!(result, dir.path().join("fnox.toml"));
1934    }
1935
1936    #[test]
1937    fn test_find_local_config_only_dotfile() {
1938        let dir = tempfile::tempdir().unwrap();
1939        std::fs::write(dir.path().join(".fnox.toml"), "").unwrap();
1940        let result = super::find_local_config(dir.path(), None);
1941        assert_eq!(result, dir.path().join(".fnox.toml"));
1942    }
1943
1944    #[test]
1945    fn test_find_local_config_profile() {
1946        let dir = tempfile::tempdir().unwrap();
1947        std::fs::write(dir.path().join("fnox.staging.toml"), "").unwrap();
1948        let result = super::find_local_config(dir.path(), Some("staging"));
1949        assert_eq!(result, dir.path().join("fnox.staging.toml"));
1950    }
1951
1952    #[test]
1953    fn test_find_local_config_profile_with_base() {
1954        let dir = tempfile::tempdir().unwrap();
1955        std::fs::write(dir.path().join("fnox.toml"), "").unwrap();
1956        std::fs::write(dir.path().join("fnox.staging.toml"), "").unwrap();
1957        let result = super::find_local_config(dir.path(), Some("staging"));
1958        // Profile-specific file is preferred when profile is active
1959        assert_eq!(result, dir.path().join("fnox.staging.toml"));
1960    }
1961
1962    #[test]
1963    fn test_find_local_config_default_profile_with_base() {
1964        // Default profile should still pick fnox.toml (lowest priority)
1965        let dir = tempfile::tempdir().unwrap();
1966        std::fs::write(dir.path().join("fnox.toml"), "").unwrap();
1967        std::fs::write(dir.path().join("fnox.local.toml"), "").unwrap();
1968        let result = super::find_local_config(dir.path(), Some("default"));
1969        assert_eq!(result, dir.path().join("fnox.toml"));
1970    }
1971
1972    #[test]
1973    fn test_find_local_config_profile_only_base_exists() {
1974        // Profile specified but only base config exists — fall back to it
1975        let dir = tempfile::tempdir().unwrap();
1976        std::fs::write(dir.path().join("fnox.toml"), "").unwrap();
1977        let result = super::find_local_config(dir.path(), Some("staging"));
1978        assert_eq!(result, dir.path().join("fnox.toml"));
1979    }
1980
1981    #[test]
1982    fn test_find_local_config_profile_skips_local_file() {
1983        // When a profile is active and only fnox.local.toml exists,
1984        // should NOT write there — fall through to creating fnox.toml
1985        let dir = tempfile::tempdir().unwrap();
1986        std::fs::write(dir.path().join("fnox.local.toml"), "").unwrap();
1987        let result = super::find_local_config(dir.path(), Some("staging"));
1988        assert_eq!(result, dir.path().join("fnox.toml"));
1989    }
1990
1991    #[test]
1992    fn test_find_local_config_no_profile_uses_local_file() {
1993        // Without a profile, fnox.local.toml is a valid write target
1994        let dir = tempfile::tempdir().unwrap();
1995        std::fs::write(dir.path().join("fnox.local.toml"), "").unwrap();
1996        let result = super::find_local_config(dir.path(), None);
1997        assert_eq!(result, dir.path().join("fnox.local.toml"));
1998    }
1999
2000    #[test]
2001    fn filter_secrets_none_allowlist_returns_all() {
2002        let cfg = McpConfig::default(); // secrets: None
2003        let mut m = IndexMap::new();
2004        m.insert("A".to_string(), SecretConfig::new());
2005        m.insert("B".to_string(), SecretConfig::new());
2006        let result = cfg.filter_secrets(m.clone());
2007        assert_eq!(
2008            result.keys().collect::<Vec<_>>(),
2009            m.keys().collect::<Vec<_>>()
2010        );
2011    }
2012
2013    #[test]
2014    fn filter_secrets_empty_allowlist_returns_empty() {
2015        let cfg = McpConfig {
2016            secrets: Some(vec![]),
2017            ..Default::default()
2018        };
2019        let mut m = IndexMap::new();
2020        m.insert("A".to_string(), SecretConfig::new());
2021        assert!(cfg.filter_secrets(m).is_empty());
2022    }
2023
2024    #[test]
2025    fn filter_secrets_subset() {
2026        let cfg = McpConfig {
2027            secrets: Some(vec!["A".into()]),
2028            ..Default::default()
2029        };
2030        let mut m = IndexMap::new();
2031        m.insert("A".to_string(), SecretConfig::new());
2032        m.insert("B".to_string(), SecretConfig::new());
2033        let result = cfg.filter_secrets(m);
2034        assert!(result.contains_key("A"));
2035        assert!(!result.contains_key("B"));
2036    }
2037
2038    #[test]
2039    fn filter_secrets_unknown_allowlist_entry_ignored() {
2040        let cfg = McpConfig {
2041            secrets: Some(vec!["A".into(), "NONEXISTENT".into()]),
2042            ..Default::default()
2043        };
2044        let mut m = IndexMap::new();
2045        m.insert("A".to_string(), SecretConfig::new());
2046        let result = cfg.filter_secrets(m);
2047        assert_eq!(result.len(), 1);
2048        assert!(result.contains_key("A"));
2049    }
2050
2051    #[test]
2052    fn mcp_secrets_overlay_replaces_base_not_appends() {
2053        let base = Config {
2054            mcp: Some(McpConfig {
2055                secrets: Some(vec!["A".into()]),
2056                ..Default::default()
2057            }),
2058            ..Config::new()
2059        };
2060        let overlay = Config {
2061            mcp: Some(McpConfig {
2062                secrets: Some(vec!["B".into()]),
2063                ..Default::default()
2064            }),
2065            ..Config::new()
2066        };
2067        let merged = Config::merge_configs(base, overlay).unwrap();
2068        assert_eq!(
2069            merged.mcp.unwrap().secrets,
2070            Some(vec!["B".into()]),
2071            "overlay must replace, not append, the base allowlist"
2072        );
2073    }
2074
2075    #[test]
2076    fn mcp_secrets_overlay_without_secrets_preserves_base() {
2077        let base = Config {
2078            mcp: Some(McpConfig {
2079                secrets: Some(vec!["A".into()]),
2080                ..Default::default()
2081            }),
2082            ..Config::new()
2083        };
2084        let overlay = Config {
2085            mcp: Some(McpConfig {
2086                ..Default::default()
2087            }),
2088            ..Config::new()
2089        };
2090        let merged = Config::merge_configs(base, overlay).unwrap();
2091        assert_eq!(merged.mcp.unwrap().secrets, Some(vec!["A".into()]));
2092    }
2093
2094    #[test]
2095    fn test_for_raw_resolve_strips_post_processing_fields() {
2096        let mut secret = SecretConfig::new();
2097        secret.set_provider(Some("plain".to_string()));
2098        secret.set_value(Some(r#"{"user":"admin"}"#.to_string()));
2099        secret.default = Some("fallback".to_string());
2100        secret.json_path = Some("user".to_string());
2101        secret.line = Some(2);
2102        secret.sync = Some(SyncConfig {
2103            provider: "age".to_string(),
2104            value: "encrypted-blob".to_string(),
2105        });
2106
2107        let raw = secret.for_raw_resolve();
2108
2109        assert!(raw.default.is_none());
2110        assert!(raw.json_path.is_none());
2111        assert!(raw.line.is_none());
2112        assert!(raw.sync.is_none());
2113    }
2114
2115    #[test]
2116    fn test_secret_config_line_roundtrip() {
2117        let toml_input = r#"
2118[secrets]
2119USERNAME = { provider = "pass", value = "master", line = 2 }
2120"#;
2121        let parsed: Config = toml_edit::de::from_str(toml_input).unwrap();
2122        let secret = parsed.secrets.get("USERNAME").unwrap();
2123        assert_eq!(secret.line, Some(2));
2124
2125        let inline = secret.to_inline_table();
2126        let rendered = inline.to_string();
2127        assert!(
2128            rendered.contains("line = 2"),
2129            "expected serialized output to contain `line = 2`, got: {rendered}"
2130        );
2131    }
2132
2133    #[test]
2134    fn test_for_raw_resolve_preserves_non_post_processing_fields() {
2135        let mut secret = SecretConfig::new();
2136        secret.set_provider(Some("plain".to_string()));
2137        secret.set_value(Some("my-secret".to_string()));
2138        secret.description = Some("A test secret".to_string());
2139        secret.if_missing = Some(IfMissing::Warn);
2140        secret.env = false;
2141        secret.as_file = true;
2142        secret.source_path = Some(PathBuf::from("/tmp/fnox.toml"));
2143        secret.source_is_profile = true;
2144        secret.default = Some("default-val".to_string());
2145        secret.json_path = Some("key".to_string());
2146        secret.sync = Some(SyncConfig {
2147            provider: "age".to_string(),
2148            value: "blob".to_string(),
2149        });
2150
2151        let raw = secret.for_raw_resolve();
2152
2153        assert_eq!(raw.provider(), Some("plain"));
2154        assert_eq!(raw.value(), Some("my-secret"));
2155        assert_eq!(raw.description.as_deref(), Some("A test secret"));
2156        assert_eq!(raw.if_missing, Some(IfMissing::Warn));
2157        assert!(!raw.env);
2158        assert!(raw.as_file);
2159        assert_eq!(
2160            raw.source_path.as_deref(),
2161            Some(Path::new("/tmp/fnox.toml"))
2162        );
2163        assert!(raw.source_is_profile);
2164    }
2165
2166    #[test]
2167    fn test_for_raw_resolve_with_no_post_processing_fields() {
2168        let mut secret = SecretConfig::new();
2169        secret.set_provider(Some("plain".to_string()));
2170        secret.set_value(Some("simple-value".to_string()));
2171
2172        let raw = secret.for_raw_resolve();
2173
2174        assert_eq!(raw.provider(), Some("plain"));
2175        assert_eq!(raw.value(), Some("simple-value"));
2176        assert!(raw.default.is_none());
2177        assert!(raw.json_path.is_none());
2178        assert!(raw.sync.is_none());
2179    }
2180}