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
17pub const DEFAULT_CONFIG_FILENAME: &str = "fnox.toml";
19
20pub 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
38pub 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
49pub fn find_local_config(dir: &Path, profile: Option<&str>) -> PathBuf {
56 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 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
87pub use crate::providers::ProviderConfig;
89
90#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
91#[serde(deny_unknown_fields)]
92pub struct Config {
93 #[serde(default, skip_serializing_if = "Vec::is_empty")]
95 pub import: Vec<String>,
96
97 #[serde(default, skip_serializing_if = "is_false")]
99 pub root: bool,
100
101 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
103 pub leases: IndexMap<String, crate::lease_backends::LeaseBackendConfig>,
104
105 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
107 pub providers: IndexMap<String, ProviderConfig>,
108
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 default_provider: Option<SpannedValue<String>>,
112
113 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
115 pub secrets: IndexMap<String, SecretConfig>,
116
117 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
119 pub profiles: IndexMap<String, ProfileConfig>,
120
121 #[serde(skip_serializing_if = "Option::is_none")]
123 pub age_key_file: Option<PathBuf>,
124
125 #[serde(skip_serializing_if = "Option::is_none")]
127 pub if_missing: Option<IfMissing>,
128
129 #[serde(skip_serializing_if = "Option::is_none")]
131 pub prompt_auth: Option<bool>,
132
133 #[serde(skip_serializing_if = "Option::is_none")]
135 pub mcp: Option<McpConfig>,
136
137 #[serde(skip)]
139 pub provider_sources: HashMap<String, PathBuf>,
140
141 #[serde(skip)]
143 pub secret_sources: HashMap<String, PathBuf>,
144
145 #[serde(skip)]
147 pub default_provider_source: Option<PathBuf>,
148
149 #[serde(skip)]
152 pub project_dir: Option<PathBuf>,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
157pub struct SyncConfig {
158 pub provider: String,
159 pub value: String,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
164#[serde(deny_unknown_fields)]
165pub struct SecretConfig {
166 #[serde(skip_serializing_if = "Option::is_none")]
168 pub description: Option<String>,
169
170 #[serde(skip_serializing_if = "Option::is_none")]
172 pub if_missing: Option<IfMissing>,
173
174 #[serde(skip_serializing_if = "Option::is_none")]
176 pub default: Option<String>,
177
178 #[serde(skip_serializing_if = "Option::is_none")]
180 provider: Option<SpannedValue<String>>,
181
182 #[serde(skip_serializing_if = "Option::is_none")]
184 value: Option<SpannedValue<String>>,
185
186 #[serde(default = "default_true", skip_serializing_if = "is_true")]
189 pub env: bool,
190
191 #[serde(default, skip_serializing_if = "is_false")]
193 pub as_file: bool,
194 #[serde(skip_serializing_if = "Option::is_none")]
197 pub json_path: Option<String>,
198
199 #[serde(skip_serializing_if = "Option::is_none")]
204 #[schemars(range(min = 1))]
205 pub line: Option<usize>,
206
207 #[serde(skip_serializing_if = "Option::is_none")]
209 pub sync: Option<SyncConfig>,
210
211 #[serde(skip)]
213 pub source_path: Option<PathBuf>,
214
215 #[serde(skip)]
218 pub source_is_profile: bool,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
223#[serde(deny_unknown_fields)]
224pub struct ProfileConfig {
225 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
227 pub leases: IndexMap<String, crate::lease_backends::LeaseBackendConfig>,
228
229 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
231 pub providers: IndexMap<String, ProviderConfig>,
232
233 #[serde(default, skip_serializing_if = "Option::is_none")]
235 default_provider: Option<SpannedValue<String>>,
236
237 #[serde(default, skip_serializing_if = "IndexMap::is_empty")]
239 pub secrets: IndexMap<String, SecretConfig>,
240
241 #[serde(skip)]
243 pub provider_sources: HashMap<String, PathBuf>,
244
245 #[serde(skip)]
247 pub secret_sources: HashMap<String, PathBuf>,
248
249 #[serde(skip)]
251 pub default_provider_source: Option<PathBuf>,
252}
253
254#[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 pub fn tool_name(&self) -> &'static str {
265 match self {
266 McpTool::GetSecret => "get_secret",
267 McpTool::Exec => "exec",
268 }
269 }
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
274#[serde(deny_unknown_fields)]
275#[derive(Default)]
276pub struct McpConfig {
277 #[serde(default, skip_serializing_if = "Option::is_none", rename = "tools")]
279 tools_raw: Option<Vec<McpTool>>,
280
281 #[serde(skip_serializing_if = "Option::is_none")]
283 #[schemars(range(min = 1))]
284 pub exec_timeout_secs: Option<u64>,
285
286 #[serde(skip_serializing_if = "Option::is_none")]
290 pub redact_output: Option<bool>,
291
292 #[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 pub fn tools_explicitly_set(&self) -> bool {
306 self.tools_raw.is_some()
307 }
308
309 pub fn tools(&self) -> Vec<McpTool> {
311 self.tools_raw.clone().unwrap_or_else(Self::default_tools)
312 }
313
314 pub fn set_tools(&mut self, tools: Vec<McpTool>) {
316 self.tools_raw = Some(tools);
317 }
318
319 pub fn redact_output(&self) -> bool {
321 self.redact_output.unwrap_or(true)
322 }
323
324 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 pub fn load_smart<P: AsRef<Path>>(path: P) -> Result<Self> {
357 let path_ref = path.as_ref();
358
359 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 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 Self::load(resolved_path)
376 }
377 }
378
379 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 source_registry::register(path, content.clone());
391
392 let mut config: Config = toml_edit::de::from_str(&content).map_err(|e| {
393 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 FnoxError::ConfigParseError { source: e }
406 }
407 })?;
408
409 config.set_source_paths(path);
411
412 Ok(config)
413 }
414
415 fn load_with_recursion<P: AsRef<Path>>(_start_path: P) -> Result<Self> {
417 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(¤t_dir, false) {
422 Ok((_config, found)) if !found => {
423 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 config.project_dir = Self::find_project_dir(¤t_dir);
436 Ok(config)
437 }
438 Err(e) => Err(e),
439 }
440 }
441
442 fn load_recursive(dir: &Path, found_any: bool) -> Result<(Self, bool)> {
445 let profile = crate::settings::Settings::get().profile.clone();
447 let filenames = all_config_filenames(Some(&profile));
448
449 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 config.root {
464 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 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 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 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 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 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 pub fn global_config_path() -> PathBuf {
520 env::FNOX_CONFIG_DIR.join("config.toml")
521 }
522
523 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 fn load_import(import_path: &str, base_dir: &Path) -> Result<Self> {
542 let path = PathBuf::from(import_path);
543
544 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 fn merge_configs(base: Config, overlay: Config) -> Result<Config> {
563 let mut merged = base;
564
565 for import_path in overlay.import {
567 if !merged.import.contains(&import_path) {
568 merged.import.push(import_path);
569 }
570 }
571
572 merged.root = merged.root || overlay.root;
574
575 if overlay.age_key_file.is_some() {
577 merged.age_key_file = overlay.age_key_file;
578 }
579
580 if overlay.if_missing.is_some() {
582 merged.if_missing = overlay.if_missing;
583 }
584
585 if overlay.prompt_auth.is_some() {
587 merged.prompt_auth = overlay.prompt_auth;
588 }
589
590 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 if overlay_mcp.secrets.is_some() {
606 base_mcp.secrets = overlay_mcp.secrets;
607 }
608 }
609
610 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 for (name, lease) in overlay.leases {
618 merged.leases.insert(name, lease);
619 }
620
621 for (name, provider) in overlay.providers {
623 merged.providers.insert(name, provider);
624 }
625
626 for (name, source) in overlay.provider_sources {
628 merged.provider_sources.insert(name, source);
629 }
630
631 for (name, secret) in overlay.secrets {
633 merged.secrets.insert(name, secret);
634 }
635
636 for (name, source) in overlay.secret_sources {
638 merged.secret_sources.insert(name, source);
639 }
640
641 for (name, profile) in overlay.profiles {
643 if let Some(existing_profile) = merged.profiles.get_mut(&name) {
644 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 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 pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
681 let mut clean_config = self.clone();
683 clean_config
684 .profiles
685 .retain(|_, profile| !profile.is_empty());
686
687 let pretty_string = toml_edit::ser::to_string_pretty(&clean_config)?;
689
690 let mut doc = pretty_string
692 .parse::<toml_edit::DocumentMut>()
693 .map_err(|e| FnoxError::Config(format!("Failed to parse TOML: {}", e)))?;
694
695 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 fn convert_secrets_to_inline(doc: &mut toml_edit::DocumentMut) -> Result<()> {
709 use toml_edit::{InlineTable, Item};
710
711 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 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 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 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 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 if let Some(mut key) = secrets_table.key_mut(secret_name) {
835 key.leaf_decor_mut().set_suffix("");
836 }
837 }
838
839 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 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 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 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 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 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 for (name, config) in secrets {
954 let name_str = name.as_str();
955
956 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 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 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 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 pub fn should_prompt_auth(&self) -> bool {
1013 let enabled = (*env::FNOX_PROMPT_AUTH)
1015 .or(self.prompt_auth)
1016 .unwrap_or(true);
1017
1018 enabled && atty::is(atty::Stream::Stdin)
1020 }
1021
1022 pub fn get_default_secrets_mut(&mut self) -> &mut IndexMap<String, SecretConfig> {
1024 &mut self.secrets
1025 }
1026
1027 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 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 IndexMap::new()
1053 } else {
1054 self.secrets.clone()
1056 };
1057
1058 if let Some(profile_config) = self.profiles.get(profile) {
1060 secrets.extend(profile_config.secrets.clone());
1062 }
1063 Ok(secrets)
1066 }
1067
1068 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 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 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 pub fn get_providers(&self, profile: &str) -> IndexMap<String, ProviderConfig> {
1115 let mut providers = self.providers.clone(); 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 pub fn get_default_provider(&self, profile: &str) -> Result<Option<String>> {
1129 let providers = self.get_providers(profile);
1130
1131 if providers.is_empty() && self.root {
1133 return Ok(None);
1134 }
1135
1136 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 if profile != "default"
1145 && let Some(profile_config) = self.profiles.get(profile)
1146 && let Some(default_provider_name) = profile_config.default_provider()
1147 {
1148 if !providers.contains_key(default_provider_name) {
1150 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 if let Some(default_provider_name) = self.default_provider() {
1174 if !providers.contains_key(default_provider_name) {
1176 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 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 Ok(None)
1210 }
1211
1212 fn set_source_paths(&mut self, path: &Path) {
1214 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 for (provider_name, _) in self.providers.iter() {
1222 self.provider_sources
1223 .insert(provider_name.clone(), path.to_path_buf());
1224 }
1225
1226 if self.default_provider().is_some() {
1228 self.default_provider_source = Some(path.to_path_buf());
1229 }
1230
1231 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 if profile.default_provider().is_some() {
1249 profile.default_provider_source = Some(path.to_path_buf());
1250 }
1251 }
1252 }
1253
1254 fn check_empty_value(
1257 &self,
1258 key: &str,
1259 secret: &SecretConfig,
1260 profile: &str,
1261 ) -> Option<crate::error::ValidationIssue> {
1262 let Some(value) = secret.value() else {
1264 return None; };
1266 if !value.is_empty() {
1267 return None; }
1269
1270 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 fn is_plain_provider(&self, secret_provider: Option<&str>, profile: &str) -> bool {
1292 let providers = self.get_providers(profile);
1294
1295 let provider_name = secret_provider
1297 .map(String::from)
1298 .or_else(|| {
1299 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 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 providers
1324 .get(&provider_name)
1325 .is_some_and(|p| p.provider_type() == "plain")
1326 }
1327
1328 pub fn validate(&self) -> Result<()> {
1331 use crate::error::ValidationIssue;
1332
1333 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 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 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 let Some(default_provider_name) = self.default_provider()
1361 && !self.providers.contains_key(default_provider_name)
1362 {
1363 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 for (profile_name, profile_config) in &self.profiles {
1391 let providers = self.get_providers(profile_name);
1392
1393 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 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 let Some(default_provider_name) = profile_config.default_provider()
1413 && !providers.contains_key(default_provider_name)
1414 {
1415 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 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 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 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 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 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 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 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 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 pub fn has_value(&self) -> bool {
1629 self.provider().is_some() || self.value().is_some() || self.default.is_some()
1630 }
1631
1632 pub fn provider(&self) -> Option<&str> {
1634 self.provider.as_ref().map(|s| s.value().as_str())
1635 }
1636
1637 pub fn provider_span(&self) -> Option<Range<usize>> {
1640 self.provider.as_ref().and_then(|s| s.span())
1641 }
1642
1643 pub fn set_provider(&mut self, provider: Option<String>) {
1645 self.provider = provider.map(SpannedValue::without_span);
1646 }
1647
1648 pub fn value(&self) -> Option<&str> {
1650 self.value
1651 .as_ref()
1652 .map(|s: &SpannedValue<String>| s.value().as_str())
1653 }
1654
1655 pub fn set_value(&mut self, value: Option<String>) {
1657 self.value = value.map(SpannedValue::without_span);
1658 }
1659}
1660
1661impl ProfileConfig {
1662 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 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 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 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 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 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 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 config
1830 .profiles
1831 .insert("prod".to_string(), ProfileConfig::new());
1832
1833 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 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 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 assert_eq!(result, dir.path().join("fnox.staging.toml"));
1960 }
1961
1962 #[test]
1963 fn test_find_local_config_default_profile_with_base() {
1964 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 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 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 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(); 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}