use crate::env;
use crate::error::{FnoxError, Result};
use crate::settings::Settings;
use crate::source_registry;
use crate::spanned::SpannedValue;
use clap::ValueEnum;
use indexmap::IndexMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::ops::Range;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use strum::VariantNames;
pub const DEFAULT_CONFIG_FILENAME: &str = "fnox.toml";
pub fn all_config_filenames(profile: Option<&str>) -> Vec<String> {
let mut files = vec![
DEFAULT_CONFIG_FILENAME.to_string(),
".fnox.toml".to_string(),
];
if let Some(p) = profile.filter(|p| *p != "default") {
files.push(format!("fnox.{p}.toml"));
files.push(format!(".fnox.{p}.toml"));
}
files.push("fnox.local.toml".to_string());
files.push(".fnox.local.toml".to_string());
files
}
pub fn local_override_filename(path: &Path) -> Option<&'static str> {
match path.file_name().and_then(|name| name.to_str()) {
Some("fnox.toml") => Some("fnox.local.toml"),
Some(".fnox.toml") => Some(".fnox.local.toml"),
_ => None,
}
}
pub fn find_local_config(dir: &Path, profile: Option<&str>) -> PathBuf {
if let Some(p) = profile.filter(|p| *p != "default") {
for name in [format!("fnox.{p}.toml"), format!(".fnox.{p}.toml")] {
let path = dir.join(&name);
if path.exists() {
return path;
}
}
}
let is_profiled = profile.is_some_and(|p| p != "default");
for name in &["fnox.toml", ".fnox.toml"] {
let path = dir.join(name);
if path.exists() {
return path;
}
}
if !is_profiled {
for name in &["fnox.local.toml", ".fnox.local.toml"] {
let path = dir.join(name);
if path.exists() {
return path;
}
}
}
dir.join(DEFAULT_CONFIG_FILENAME)
}
pub use crate::providers::ProviderConfig;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub import: Vec<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub root: bool,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub leases: IndexMap<String, crate::lease_backends::LeaseBackendConfig>,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub providers: IndexMap<String, ProviderConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
default_provider: Option<SpannedValue<String>>,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub secrets: IndexMap<String, SecretConfig>,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub profiles: IndexMap<String, ProfileConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub age_key_file: Option<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub if_missing: Option<IfMissing>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_auth: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mcp: Option<McpConfig>,
#[serde(skip)]
pub provider_sources: HashMap<String, PathBuf>,
#[serde(skip)]
pub secret_sources: HashMap<String, PathBuf>,
#[serde(skip)]
pub default_provider_source: Option<PathBuf>,
#[serde(skip)]
pub project_dir: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SyncConfig {
pub provider: String,
pub value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SecretConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub if_missing: Option<IfMissing>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
provider: Option<SpannedValue<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
value: Option<SpannedValue<String>>,
#[serde(default = "default_true", skip_serializing_if = "is_true")]
pub env: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub as_file: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub json_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub line: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sync: Option<SyncConfig>,
#[serde(skip)]
pub source_path: Option<PathBuf>,
#[serde(skip)]
pub source_is_profile: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ProfileConfig {
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub leases: IndexMap<String, crate::lease_backends::LeaseBackendConfig>,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub providers: IndexMap<String, ProviderConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
default_provider: Option<SpannedValue<String>>,
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
pub secrets: IndexMap<String, SecretConfig>,
#[serde(skip)]
pub provider_sources: HashMap<String, PathBuf>,
#[serde(skip)]
pub secret_sources: HashMap<String, PathBuf>,
#[serde(skip)]
pub default_provider_source: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum McpTool {
GetSecret,
Exec,
}
impl McpTool {
pub fn tool_name(&self) -> &'static str {
match self {
McpTool::GetSecret => "get_secret",
McpTool::Exec => "exec",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[derive(Default)]
pub struct McpConfig {
#[serde(default, skip_serializing_if = "Option::is_none", rename = "tools")]
tools_raw: Option<Vec<McpTool>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub exec_timeout_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub redact_output: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub secrets: Option<Vec<String>>,
}
impl McpConfig {
fn default_tools() -> Vec<McpTool> {
vec![McpTool::GetSecret, McpTool::Exec]
}
pub fn tools_explicitly_set(&self) -> bool {
self.tools_raw.is_some()
}
pub fn tools(&self) -> Vec<McpTool> {
self.tools_raw.clone().unwrap_or_else(Self::default_tools)
}
pub fn set_tools(&mut self, tools: Vec<McpTool>) {
self.tools_raw = Some(tools);
}
pub fn redact_output(&self) -> bool {
self.redact_output.unwrap_or(true)
}
pub fn filter_secrets(
&self,
secrets: IndexMap<String, SecretConfig>,
) -> IndexMap<String, SecretConfig> {
match &self.secrets {
None => secrets,
Some(allowlist) => {
let allowed: std::collections::HashSet<&str> =
allowlist.iter().map(|s| s.as_str()).collect();
secrets
.into_iter()
.filter(|(k, _)| allowed.contains(k.as_str()))
.collect()
}
}
}
}
#[derive(
Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq, ValueEnum, VariantNames,
)]
#[serde(rename_all = "lowercase")]
pub enum IfMissing {
Error,
Warn,
Ignore,
}
impl Config {
pub fn load_smart<P: AsRef<Path>>(path: P) -> Result<Self> {
let path_ref = path.as_ref();
let default_filenames = all_config_filenames(None);
if default_filenames.iter().any(|f| path_ref == Path::new(f)) {
Self::load_with_recursion(path_ref)
} else {
let resolved_path = if path_ref.is_relative() {
env::current_dir()
.map_err(|e| {
FnoxError::Config(format!("Failed to get current directory: {}", e))
})?
.join(path_ref)
} else {
path_ref.to_path_buf()
};
Self::load(resolved_path)
}
}
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
use miette::{NamedSource, SourceSpan};
let path = path.as_ref();
let content = fs::read_to_string(path).map_err(|source| FnoxError::ConfigReadFailed {
path: path.to_path_buf(),
source,
})?;
source_registry::register(path, content.clone());
let mut config: Config = toml_edit::de::from_str(&content).map_err(|e| {
if let Some(span) = e.span() {
FnoxError::ConfigParseErrorWithSource {
message: e.message().to_string(),
src: Arc::new(NamedSource::new(
path.display().to_string(),
Arc::new(content),
)),
span: SourceSpan::new(span.start.into(), span.end - span.start),
}
} else {
FnoxError::ConfigParseError { source: e }
}
})?;
config.set_source_paths(path);
Ok(config)
}
fn load_with_recursion<P: AsRef<Path>>(_start_path: P) -> Result<Self> {
let current_dir = env::current_dir()
.map_err(|e| FnoxError::Config(format!("Failed to get current directory: {}", e)))?;
match Self::load_recursive(¤t_dir, false) {
Ok((_config, found)) if !found => {
Err(FnoxError::ConfigNotFound {
message: format!(
"No configuration file found in {} or any parent directory",
current_dir.display()
),
help: "Run 'fnox init' to create a configuration file".to_string(),
})
}
Ok((mut config, _)) => {
config.project_dir = Self::find_project_dir(¤t_dir);
Ok(config)
}
Err(e) => Err(e),
}
}
fn load_recursive(dir: &Path, found_any: bool) -> Result<(Self, bool)> {
let profile = crate::settings::Settings::get().profile.clone();
let filenames = all_config_filenames(Some(&profile));
let mut config = Self::new();
let mut found = found_any;
for filename in &filenames {
let path = dir.join(filename);
if path.exists() {
let file_config = Self::load(&path)?;
config = Self::merge_configs(config, file_config)?;
found = true;
}
}
if config.root {
for import_path in &config.import.clone() {
let import_config = Self::load_import(import_path, dir)?;
config = Self::merge_configs(import_config, config)?;
}
let (global_config, global_found) = Self::load_global()?;
if global_found {
config = Self::merge_configs(global_config, config)?;
found = true;
}
return Ok((config, found));
}
for import_path in &config.import.clone() {
let import_config = Self::load_import(import_path, dir)?;
config = Self::merge_configs(import_config, config)?;
}
if let Some(parent_dir) = dir.parent() {
let (parent_config, parent_found) = Self::load_recursive(parent_dir, found)?;
config = Self::merge_configs(parent_config, config)?;
found = found || parent_found;
} else {
let (global_config, global_found) = Self::load_global()?;
if global_found {
config = Self::merge_configs(global_config, config)?;
found = true;
}
}
Ok((config, found))
}
fn find_project_dir(start: &Path) -> Option<PathBuf> {
let profile = crate::settings::Settings::get().profile.clone();
let filenames = all_config_filenames(Some(&profile));
let mut dir = Some(start);
while let Some(d) = dir {
for filename in &filenames {
if d.join(filename).exists() {
return Some(d.to_path_buf());
}
}
dir = d.parent();
}
None
}
pub fn global_config_path() -> PathBuf {
env::FNOX_CONFIG_DIR.join("config.toml")
}
fn load_global() -> Result<(Self, bool)> {
let global_config_path = Self::global_config_path();
if global_config_path.exists() {
tracing::debug!(
"Loading global config from {}",
global_config_path.display()
);
let config = Self::load(&global_config_path)?;
Ok((config, true))
} else {
Ok((Self::new(), false))
}
}
fn load_import(import_path: &str, base_dir: &Path) -> Result<Self> {
let path = PathBuf::from(import_path);
let absolute_path = if path.is_absolute() {
path
} else {
base_dir.join(path)
};
if !absolute_path.exists() {
return Err(FnoxError::Config(format!(
"Import file not found: {}",
absolute_path.display()
)));
}
Self::load(&absolute_path)
}
fn merge_configs(base: Config, overlay: Config) -> Result<Config> {
let mut merged = base;
for import_path in overlay.import {
if !merged.import.contains(&import_path) {
merged.import.push(import_path);
}
}
merged.root = merged.root || overlay.root;
if overlay.age_key_file.is_some() {
merged.age_key_file = overlay.age_key_file;
}
if overlay.if_missing.is_some() {
merged.if_missing = overlay.if_missing;
}
if overlay.prompt_auth.is_some() {
merged.prompt_auth = overlay.prompt_auth;
}
if let Some(overlay_mcp) = overlay.mcp {
let base_mcp = merged.mcp.get_or_insert_with(McpConfig::default);
if overlay_mcp.tools_explicitly_set() {
base_mcp.set_tools(overlay_mcp.tools());
}
if overlay_mcp.exec_timeout_secs.is_some() {
base_mcp.exec_timeout_secs = overlay_mcp.exec_timeout_secs;
}
if overlay_mcp.redact_output.is_some() {
base_mcp.redact_output = overlay_mcp.redact_output;
}
if overlay_mcp.secrets.is_some() {
base_mcp.secrets = overlay_mcp.secrets;
}
}
if overlay.default_provider.is_some() {
merged.default_provider = overlay.default_provider;
merged.default_provider_source = overlay.default_provider_source;
}
for (name, lease) in overlay.leases {
merged.leases.insert(name, lease);
}
for (name, provider) in overlay.providers {
merged.providers.insert(name, provider);
}
for (name, source) in overlay.provider_sources {
merged.provider_sources.insert(name, source);
}
for (name, secret) in overlay.secrets {
merged.secrets.insert(name, secret);
}
for (name, source) in overlay.secret_sources {
merged.secret_sources.insert(name, source);
}
for (name, profile) in overlay.profiles {
if let Some(existing_profile) = merged.profiles.get_mut(&name) {
for (lease_name, lease) in profile.leases {
existing_profile.leases.insert(lease_name, lease);
}
for (provider_name, provider) in profile.providers {
existing_profile.providers.insert(provider_name, provider);
}
for (provider_name, source) in &profile.provider_sources {
existing_profile
.provider_sources
.insert(provider_name.clone(), source.clone());
}
for (secret_name, secret) in profile.secrets {
existing_profile.secrets.insert(secret_name, secret);
}
for (secret_name, source) in &profile.secret_sources {
existing_profile
.secret_sources
.insert(secret_name.clone(), source.clone());
}
if profile.default_provider.is_some() {
existing_profile.default_provider = profile.default_provider;
existing_profile.default_provider_source = profile.default_provider_source;
}
} else {
merged.profiles.insert(name, profile);
}
}
Ok(merged)
}
pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<()> {
let mut clean_config = self.clone();
clean_config
.profiles
.retain(|_, profile| !profile.is_empty());
let pretty_string = toml_edit::ser::to_string_pretty(&clean_config)?;
let mut doc = pretty_string
.parse::<toml_edit::DocumentMut>()
.map_err(|e| FnoxError::Config(format!("Failed to parse TOML: {}", e)))?;
Self::convert_secrets_to_inline(&mut doc)?;
fs::write(path.as_ref(), doc.to_string()).map_err(|source| {
FnoxError::ConfigWriteFailed {
path: path.as_ref().to_path_buf(),
source,
}
})?;
Ok(())
}
fn convert_secrets_to_inline(doc: &mut toml_edit::DocumentMut) -> Result<()> {
use toml_edit::{InlineTable, Item};
if let Some(secrets_item) = doc.get_mut("secrets")
&& let Some(secrets_table) = secrets_item.as_table_mut()
{
let keys: Vec<String> = secrets_table.iter().map(|(k, _)| k.to_string()).collect();
for key in keys {
if let Some(item) = secrets_table.get_mut(&key)
&& let Some(table) = item.as_table()
{
let mut inline = InlineTable::new();
for (k, v) in table.iter() {
if let Some(value) = v.as_value() {
inline.insert(k, value.clone());
}
}
inline.fmt();
*item = Item::Value(toml_edit::Value::InlineTable(inline));
}
}
}
if let Some(profiles_item) = doc.get_mut("profiles")
&& let Some(profiles_table) = profiles_item.as_table_mut()
{
let profile_names: Vec<String> =
profiles_table.iter().map(|(k, _)| k.to_string()).collect();
for profile_name in profile_names {
if let Some(profile_item) = profiles_table.get_mut(&profile_name)
&& let Some(profile_table) = profile_item.as_table_mut()
&& let Some(secrets_item) = profile_table.get_mut("secrets")
&& let Some(secrets_table) = secrets_item.as_table_mut()
{
let keys: Vec<String> =
secrets_table.iter().map(|(k, _)| k.to_string()).collect();
for key in keys {
if let Some(item) = secrets_table.get_mut(&key)
&& let Some(table) = item.as_table()
{
let mut inline = InlineTable::new();
for (k, v) in table.iter() {
if let Some(value) = v.as_value() {
inline.insert(k, value.clone());
}
}
inline.fmt();
*item = Item::Value(toml_edit::Value::InlineTable(inline));
}
}
}
}
}
Ok(())
}
pub fn save_secret_to_source(
&self,
secret_name: &str,
secret_config: &SecretConfig,
profile: &str,
default_target: &Path,
) -> Result<()> {
use toml_edit::{DocumentMut, Item, Value};
let target_file = default_target.to_path_buf();
let mut doc = if target_file.exists() {
let content =
fs::read_to_string(&target_file).map_err(|source| FnoxError::ConfigReadFailed {
path: target_file.clone(),
source,
})?;
content.parse::<DocumentMut>().map_err(|e| {
FnoxError::Config(format!(
"Failed to parse TOML in {}: {}",
target_file.display(),
e
))
})?
} else {
DocumentMut::new()
};
let secrets_table = if profile == "default" {
if doc.get("secrets").is_none() {
doc["secrets"] = Item::Table(toml_edit::Table::new());
}
doc["secrets"].as_table_mut().unwrap()
} else {
if doc.get("profiles").is_none() {
doc["profiles"] = Item::Table(toml_edit::Table::new());
}
let profiles = doc["profiles"].as_table_mut().unwrap();
if profiles.get(profile).is_none() {
profiles[profile] = Item::Table(toml_edit::Table::new());
}
let profile_table = profiles[profile].as_table_mut().unwrap();
if profile_table.get("secrets").is_none() {
profile_table["secrets"] = Item::Table(toml_edit::Table::new());
}
profile_table["secrets"].as_table_mut().unwrap()
};
let inline = secret_config.to_inline_table();
secrets_table[secret_name] = Item::Value(Value::InlineTable(inline));
if let Some(mut key) = secrets_table.key_mut(secret_name) {
key.leaf_decor_mut().set_suffix("");
}
fs::write(&target_file, doc.to_string()).map_err(|source| {
FnoxError::ConfigWriteFailed {
path: target_file,
source,
}
})?;
Ok(())
}
pub fn remove_secret_from_source(
secret_name: &str,
profile: &str,
target_file: &Path,
) -> Result<bool> {
use toml_edit::DocumentMut;
let content =
fs::read_to_string(target_file).map_err(|source| FnoxError::ConfigReadFailed {
path: target_file.to_path_buf(),
source,
})?;
let mut doc = content.parse::<DocumentMut>().map_err(|e| {
FnoxError::Config(format!(
"Failed to parse TOML in {}: {}",
target_file.display(),
e
))
})?;
let removed = if profile == "default" {
doc.get_mut("secrets")
.and_then(|s| s.as_table_mut())
.map(|t| t.remove(secret_name).is_some())
.unwrap_or(false)
} else {
doc.get_mut("profiles")
.and_then(|p| p.as_table_mut())
.and_then(|p| p.get_mut(profile))
.and_then(|p| p.as_table_mut())
.and_then(|p| p.get_mut("secrets"))
.and_then(|s| s.as_table_mut())
.map(|t| t.remove(secret_name).is_some())
.unwrap_or(false)
};
if removed {
fs::write(target_file, doc.to_string()).map_err(|source| {
FnoxError::ConfigWriteFailed {
path: target_file.to_path_buf(),
source,
}
})?;
}
Ok(removed)
}
pub fn save_secrets_to_source(
secrets: &IndexMap<String, SecretConfig>,
profile: &str,
target_file: &Path,
) -> Result<()> {
use toml_edit::{DocumentMut, Item, Value};
let mut doc = if target_file.exists() {
let content =
fs::read_to_string(target_file).map_err(|source| FnoxError::ConfigReadFailed {
path: target_file.to_path_buf(),
source,
})?;
content.parse::<DocumentMut>().map_err(|e| {
FnoxError::Config(format!(
"Failed to parse TOML in {}: {}",
target_file.display(),
e
))
})?
} else {
DocumentMut::new()
};
let secrets_table = if profile == "default" {
if doc.get("secrets").is_none() {
doc["secrets"] = Item::Table(toml_edit::Table::new());
}
doc["secrets"].as_table_mut().unwrap()
} else {
if doc.get("profiles").is_none() {
doc["profiles"] = Item::Table(toml_edit::Table::new());
}
let profiles = doc["profiles"].as_table_mut().unwrap();
if profiles.get(profile).is_none() {
profiles[profile] = Item::Table(toml_edit::Table::new());
}
let profile_table = profiles[profile].as_table_mut().unwrap();
if profile_table.get("secrets").is_none() {
profile_table["secrets"] = Item::Table(toml_edit::Table::new());
}
profile_table["secrets"].as_table_mut().unwrap()
};
for (name, config) in secrets {
let inline = config.to_inline_table();
if let Some(item) = secrets_table.get_mut(name.as_str()) {
if let Item::Value(Value::InlineTable(existing_inline)) = item {
*existing_inline = inline;
} else {
*item = Item::Value(Value::InlineTable(inline));
}
} else {
secrets_table[name.as_str()] = Item::Value(Value::InlineTable(inline));
if let Some(mut key) = secrets_table.key_mut(name.as_str()) {
key.leaf_decor_mut().set_suffix("");
}
}
}
fs::write(target_file, doc.to_string()).map_err(|source| FnoxError::ConfigWriteFailed {
path: target_file.to_path_buf(),
source,
})?;
Ok(())
}
pub fn new() -> Self {
Self {
import: Vec::new(),
root: false,
leases: IndexMap::new(),
providers: IndexMap::new(),
default_provider: None,
secrets: IndexMap::new(),
profiles: IndexMap::new(),
age_key_file: None,
if_missing: None,
prompt_auth: None,
mcp: None,
provider_sources: HashMap::new(),
secret_sources: HashMap::new(),
default_provider_source: None,
project_dir: None,
}
}
pub fn get_profile(profile_flag: Option<&str>) -> String {
profile_flag
.map(String::from)
.or_else(|| (*env::FNOX_PROFILE).clone())
.unwrap_or_else(|| "default".to_string())
}
pub fn should_prompt_auth(&self) -> bool {
let enabled = (*env::FNOX_PROMPT_AUTH)
.or(self.prompt_auth)
.unwrap_or(true);
enabled && atty::is(atty::Stream::Stdin)
}
pub fn get_default_secrets_mut(&mut self) -> &mut IndexMap<String, SecretConfig> {
&mut self.secrets
}
pub fn get_profile_secrets_mut(
&mut self,
profile: &str,
) -> &mut IndexMap<String, SecretConfig> {
&mut self
.profiles
.entry(profile.to_string())
.or_default()
.secrets
}
pub fn get_secrets(&self, profile: &str) -> Result<IndexMap<String, SecretConfig>> {
if profile == "default" {
return Ok(self.secrets.clone());
}
let mut secrets = if Settings::get().no_defaults {
IndexMap::new()
} else {
self.secrets.clone()
};
if let Some(profile_config) = self.profiles.get(profile) {
secrets.extend(profile_config.secrets.clone());
}
Ok(secrets)
}
pub fn get_secret(&self, profile: &str, key: &str) -> Option<&SecretConfig> {
if profile != "default"
&& let Some(profile_config) = self.profiles.get(profile)
&& let Some(secret) = profile_config.secrets.get(key)
{
return Some(secret);
}
if profile != "default" && Settings::get().no_defaults {
return None;
}
self.secrets.get(key)
}
pub fn get_secrets_mut(&mut self, profile: &str) -> &mut IndexMap<String, SecretConfig> {
if profile == "default" {
self.get_default_secrets_mut()
} else {
self.get_profile_secrets_mut(profile)
}
}
pub fn get_leases(
&self,
profile: &str,
) -> IndexMap<String, crate::lease_backends::LeaseBackendConfig> {
let mut leases = self.leases.clone();
if profile != "default"
&& let Some(profile_config) = self.profiles.get(profile)
{
leases.extend(profile_config.leases.clone());
}
leases
}
pub fn get_providers(&self, profile: &str) -> IndexMap<String, ProviderConfig> {
let mut providers = self.providers.clone();
if profile != "default"
&& let Some(profile_config) = self.profiles.get(profile)
{
providers.extend(profile_config.providers.clone());
}
providers
}
pub fn get_default_provider(&self, profile: &str) -> Result<Option<String>> {
let providers = self.get_providers(profile);
if providers.is_empty() && self.root {
return Ok(None);
}
if providers.is_empty() {
return Err(FnoxError::Config(
"No providers configured. Add at least one provider to fnox.toml".to_string(),
));
}
if profile != "default"
&& let Some(profile_config) = self.profiles.get(profile)
&& let Some(default_provider_name) = profile_config.default_provider()
{
if !providers.contains_key(default_provider_name) {
if let Some(source_path) = &profile_config.default_provider_source
&& let (Some(src), Some(span)) = (
source_registry::get_named_source(source_path),
profile_config.default_provider_span(),
)
{
return Err(FnoxError::DefaultProviderNotFoundWithSource {
provider: default_provider_name.to_string(),
profile: profile.to_string(),
src,
span: span.into(),
});
}
return Err(FnoxError::Config(format!(
"Default provider '{}' not found in profile '{}'",
default_provider_name, profile
)));
}
return Ok(Some(default_provider_name.to_string()));
}
if let Some(default_provider_name) = self.default_provider() {
if !providers.contains_key(default_provider_name) {
if let Some(source_path) = &self.default_provider_source
&& let (Some(src), Some(span)) = (
source_registry::get_named_source(source_path),
self.default_provider_span(),
)
{
return Err(FnoxError::DefaultProviderNotFoundWithSource {
provider: default_provider_name.to_string(),
profile: profile.to_string(),
src,
span: span.into(),
});
}
return Err(FnoxError::Config(format!(
"Default provider '{}' not found in configuration",
default_provider_name
)));
}
return Ok(Some(default_provider_name.to_string()));
}
if providers.len() == 1 {
let provider_name = providers.keys().next().unwrap().clone();
tracing::debug!(
"Auto-selecting provider '{}' as it's the only one configured",
provider_name
);
return Ok(Some(provider_name));
}
Ok(None)
}
fn set_source_paths(&mut self, path: &Path) {
for (key, secret) in self.secrets.iter_mut() {
secret.source_path = Some(path.to_path_buf());
self.secret_sources.insert(key.clone(), path.to_path_buf());
}
for (provider_name, _) in self.providers.iter() {
self.provider_sources
.insert(provider_name.clone(), path.to_path_buf());
}
if self.default_provider().is_some() {
self.default_provider_source = Some(path.to_path_buf());
}
for (_profile_name, profile) in self.profiles.iter_mut() {
for (key, secret) in profile.secrets.iter_mut() {
secret.source_path = Some(path.to_path_buf());
secret.source_is_profile = true;
profile
.secret_sources
.insert(key.clone(), path.to_path_buf());
}
for (provider_name, _) in profile.providers.iter() {
profile
.provider_sources
.insert(provider_name.clone(), path.to_path_buf());
}
if profile.default_provider().is_some() {
profile.default_provider_source = Some(path.to_path_buf());
}
}
}
fn check_empty_value(
&self,
key: &str,
secret: &SecretConfig,
profile: &str,
) -> Option<crate::error::ValidationIssue> {
let Some(value) = secret.value() else {
return None; };
if !value.is_empty() {
return None; }
if self.is_plain_provider(secret.provider(), profile) {
return None;
}
let message = if profile == "default" {
format!("Secret '{}' has an empty value", key)
} else {
format!(
"Secret '{}' in profile '{}' has an empty value",
key, profile
)
};
Some(crate::error::ValidationIssue::with_help(
message,
"Set a value for this secret or remove it from the configuration",
))
}
fn is_plain_provider(&self, secret_provider: Option<&str>, profile: &str) -> bool {
let providers = self.get_providers(profile);
let provider_name = secret_provider
.map(String::from)
.or_else(|| {
if profile != "default" {
self.profiles
.get(profile)
.and_then(|p| p.default_provider().map(|s| s.to_string()))
} else {
None
}
})
.or_else(|| self.default_provider().map(|s| s.to_string()))
.or_else(|| {
if providers.len() == 1 {
providers.keys().next().cloned()
} else {
None
}
});
let Some(provider_name) = provider_name else {
return false;
};
providers
.get(&provider_name)
.is_some_and(|p| p.provider_type() == "plain")
}
pub fn validate(&self) -> Result<()> {
use crate::error::ValidationIssue;
if self.root
&& self.providers.is_empty()
&& self.profiles.is_empty()
&& self.secrets.is_empty()
{
return Ok(());
}
let mut issues = Vec::new();
for (key, secret) in &self.secrets {
if let Some(issue) = self.check_empty_value(key, secret, "default") {
issues.push(issue);
}
}
if self.providers.is_empty() && self.profiles.is_empty() && !self.secrets.is_empty() {
issues.push(ValidationIssue::with_help(
"No providers configured",
"Add at least one provider to fnox.toml",
));
}
if let Some(default_provider_name) = self.default_provider()
&& !self.providers.contains_key(default_provider_name)
{
if let Some(source_path) = &self.default_provider_source
&& let (Some(src), Some(span)) = (
source_registry::get_named_source(source_path),
self.default_provider_span(),
)
{
return Err(FnoxError::DefaultProviderNotFoundWithSource {
provider: default_provider_name.to_string(),
profile: "default".to_string(),
src,
span: span.into(),
});
}
issues.push(ValidationIssue::with_help(
format!(
"Default provider '{}' not found in configuration",
default_provider_name
),
format!(
"Add [providers.{}] to your config or remove the default_provider setting",
default_provider_name
),
));
}
for (profile_name, profile_config) in &self.profiles {
let providers = self.get_providers(profile_name);
for (key, secret) in &profile_config.secrets {
if let Some(issue) = self.check_empty_value(key, secret, profile_name) {
issues.push(issue);
}
}
if providers.is_empty() && !self.root {
issues.push(ValidationIssue::with_help(
format!("Profile '{}' has no providers configured", profile_name),
format!(
"Add [profiles.{}.providers.<name>] or inherit from top-level providers",
profile_name
),
));
}
if let Some(default_provider_name) = profile_config.default_provider()
&& !providers.contains_key(default_provider_name)
{
if let Some(source_path) = &profile_config.default_provider_source
&& let (Some(src), Some(span)) = (
source_registry::get_named_source(source_path),
profile_config.default_provider_span(),
)
{
return Err(FnoxError::DefaultProviderNotFoundWithSource {
provider: default_provider_name.to_string(),
profile: profile_name.clone(),
src,
span: span.into(),
});
}
issues.push(ValidationIssue::with_help(
format!(
"Default provider '{}' not found in profile '{}'",
default_provider_name, profile_name
),
format!(
"Add [profiles.{}.providers.{}] or remove the default_provider setting",
profile_name, default_provider_name
),
));
}
}
if issues.is_empty() {
Ok(())
} else {
Err(FnoxError::ConfigValidationFailed { issues })
}
}
pub fn default_provider(&self) -> Option<&str> {
self.default_provider
.as_ref()
.map(|s: &SpannedValue<String>| s.value().as_str())
}
pub fn default_provider_span(&self) -> Option<Range<usize>> {
self.default_provider
.as_ref()
.and_then(|s: &SpannedValue<String>| s.span())
}
pub fn set_default_provider(&mut self, provider: Option<String>) {
self.default_provider = provider.map(SpannedValue::without_span);
}
}
impl Default for Config {
fn default() -> Self {
Self::new()
}
}
impl SecretConfig {
pub fn new() -> Self {
Self {
description: None,
if_missing: None,
default: None,
provider: None,
value: None,
env: true,
as_file: false,
json_path: None,
line: None,
sync: None,
source_path: None,
source_is_profile: false,
}
}
pub fn for_raw_resolve(&self) -> Self {
let mut config = self.clone();
config.json_path = None;
config.line = None;
config.sync = None;
config.default = None;
config
}
pub fn to_inline_table(&self) -> toml_edit::InlineTable {
let mut inline = toml_edit::InlineTable::new();
if let Some(provider) = self.provider() {
inline.insert("provider", toml_edit::Value::from(provider));
}
if let Some(value) = self.value() {
inline.insert("value", toml_edit::Value::from(value));
}
if let Some(ref json_path) = self.json_path {
inline.insert("json_path", toml_edit::Value::from(json_path.as_str()));
}
if let Some(line) = self.line {
inline.insert("line", toml_edit::Value::from(line as i64));
}
if let Some(ref description) = self.description {
inline.insert("description", toml_edit::Value::from(description.as_str()));
}
if let Some(ref default) = self.default {
inline.insert("default", toml_edit::Value::from(default.as_str()));
}
if let Some(if_missing) = self.if_missing {
let if_missing_str = match if_missing {
IfMissing::Error => "error",
IfMissing::Warn => "warn",
IfMissing::Ignore => "ignore",
};
inline.insert("if_missing", toml_edit::Value::from(if_missing_str));
}
if !self.env {
inline.insert("env", toml_edit::Value::from(false));
}
if self.as_file {
inline.insert("as_file", toml_edit::Value::from(true));
}
if let Some(ref sync) = self.sync {
let mut sync_table = toml_edit::InlineTable::new();
sync_table.insert("provider", toml_edit::Value::from(sync.provider.as_str()));
sync_table.insert("value", toml_edit::Value::from(sync.value.as_str()));
sync_table.fmt();
inline.insert("sync", toml_edit::Value::InlineTable(sync_table));
}
inline.fmt();
inline
}
pub fn has_value(&self) -> bool {
self.provider().is_some() || self.value().is_some() || self.default.is_some()
}
pub fn provider(&self) -> Option<&str> {
self.provider.as_ref().map(|s| s.value().as_str())
}
pub fn provider_span(&self) -> Option<Range<usize>> {
self.provider.as_ref().and_then(|s| s.span())
}
pub fn set_provider(&mut self, provider: Option<String>) {
self.provider = provider.map(SpannedValue::without_span);
}
pub fn value(&self) -> Option<&str> {
self.value
.as_ref()
.map(|s: &SpannedValue<String>| s.value().as_str())
}
pub fn set_value(&mut self, value: Option<String>) {
self.value = value.map(SpannedValue::without_span);
}
}
impl ProfileConfig {
pub fn new() -> Self {
Self {
leases: IndexMap::new(),
providers: IndexMap::new(),
default_provider: None,
secrets: IndexMap::new(),
provider_sources: HashMap::new(),
secret_sources: HashMap::new(),
default_provider_source: None,
}
}
pub fn is_empty(&self) -> bool {
self.leases.is_empty()
&& self.providers.is_empty()
&& self.secrets.is_empty()
&& self.default_provider().is_none()
}
pub fn default_provider(&self) -> Option<&str> {
self.default_provider
.as_ref()
.map(|s: &SpannedValue<String>| s.value().as_str())
}
pub fn default_provider_span(&self) -> Option<Range<usize>> {
self.default_provider
.as_ref()
.and_then(|s: &SpannedValue<String>| s.span())
}
}
impl Default for SecretConfig {
fn default() -> Self {
Self::new()
}
}
impl Default for ProfileConfig {
fn default() -> Self {
Self::new()
}
}
fn is_false(value: &bool) -> bool {
!value
}
fn is_true(value: &bool) -> bool {
*value
}
fn default_true() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn test_empty_import_not_serialized() {
let config = Config::new();
let toml = toml_edit::ser::to_string_pretty(&config).unwrap();
assert!(
!toml.contains("import"),
"Empty import should not be serialized"
);
}
#[test]
fn test_non_empty_import_is_serialized() {
let mut config = Config::new();
config.import.push("other.toml".to_string());
let toml = toml_edit::ser::to_string_pretty(&config).unwrap();
assert!(
toml.contains("import"),
"Non-empty import should be serialized"
);
assert!(
toml.contains("other.toml"),
"Import value should be present"
);
}
#[test]
fn test_empty_profiles_not_serialized() {
let config = Config::new();
let toml = toml_edit::ser::to_string_pretty(&config).unwrap();
assert!(
!toml.contains("profiles"),
"Empty profiles should not be serialized"
);
}
#[test]
fn test_non_empty_profiles_is_serialized() {
let mut config = Config::new();
let mut prod_profile = ProfileConfig::new();
prod_profile.providers.insert(
"plain".to_string(),
ProviderConfig::Plain { auth_command: None },
);
let mut secret = SecretConfig::new();
secret.set_value(Some("test-value".to_string()));
prod_profile
.secrets
.insert("TEST_SECRET".to_string(), secret);
config.profiles.insert("prod".to_string(), prod_profile);
let toml = toml_edit::ser::to_string_pretty(&config).unwrap();
eprintln!("Generated TOML:\n{}", toml);
assert!(
toml.contains("profiles"),
"Non-empty profiles should be serialized"
);
assert!(toml.contains("prod"), "Profile name should be present");
assert!(
!toml.contains("[profiles]\n"),
"Should not have standalone [profiles] header"
);
}
#[test]
fn test_local_override_filename_matches_standard_config_names() {
assert_eq!(
local_override_filename(Path::new("nested/fnox.toml")),
Some("fnox.local.toml")
);
assert_eq!(
local_override_filename(Path::new("nested/.fnox.toml")),
Some(".fnox.local.toml")
);
}
#[test]
fn test_local_override_filename_rejects_non_standard_config_names() {
assert_eq!(
local_override_filename(Path::new("nested/custom.toml")),
None
);
assert_eq!(
local_override_filename(Path::new("nested/fnox.dev.toml")),
None
);
}
#[test]
fn test_empty_profile_not_serialized() {
use std::io::Read;
let mut config = Config::new();
config
.profiles
.insert("prod".to_string(), ProfileConfig::new());
let temp_file = std::env::temp_dir().join("fnox_test_empty_profile.toml");
config.save(&temp_file).unwrap();
let mut toml = String::new();
std::fs::File::open(&temp_file)
.unwrap()
.read_to_string(&mut toml)
.unwrap();
std::fs::remove_file(&temp_file).ok();
eprintln!("Generated TOML with empty profile:\n{}", toml);
assert!(
!toml.contains("[profiles"),
"Empty profile should not be serialized"
);
assert!(
!toml.contains("prod"),
"Empty profile name should not appear"
);
}
#[test]
fn test_no_defaults_profile_only_secrets() {
crate::settings::Settings::reset_for_tests();
crate::settings::Settings::set_cli_snapshot(crate::settings::CliSnapshot {
age_key_file: None,
profile: Some("prod".to_string()),
if_missing: None,
no_defaults: true,
});
let mut config = Config::new();
config
.secrets
.insert("DEFAULT_ONLY".to_string(), SecretConfig::new());
let mut prod_profile = ProfileConfig::new();
prod_profile
.secrets
.insert("PROD_ONLY".to_string(), SecretConfig::new());
config.profiles.insert("prod".to_string(), prod_profile);
let secrets = config.get_secrets("prod").unwrap();
assert!(secrets.contains_key("PROD_ONLY"));
assert!(!secrets.contains_key("DEFAULT_ONLY"));
}
#[test]
fn test_no_defaults_profile_without_section_is_empty() {
crate::settings::Settings::reset_for_tests();
crate::settings::Settings::set_cli_snapshot(crate::settings::CliSnapshot {
age_key_file: None,
profile: Some("prod".to_string()),
if_missing: None,
no_defaults: true,
});
let mut config = Config::new();
config
.secrets
.insert("DEFAULT_ONLY".to_string(), SecretConfig::new());
let secrets = config.get_secrets("prod").unwrap();
assert!(secrets.is_empty());
}
#[test]
fn test_find_local_config_no_files() {
let dir = tempfile::tempdir().unwrap();
let result = super::find_local_config(dir.path(), None);
assert_eq!(result, dir.path().join("fnox.toml"));
}
#[test]
fn test_find_local_config_only_fnox_toml() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("fnox.toml"), "").unwrap();
let result = super::find_local_config(dir.path(), None);
assert_eq!(result, dir.path().join("fnox.toml"));
}
#[test]
fn test_find_local_config_only_local_toml() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("fnox.local.toml"), "").unwrap();
let result = super::find_local_config(dir.path(), None);
assert_eq!(result, dir.path().join("fnox.local.toml"));
}
#[test]
fn test_find_local_config_both_exist() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("fnox.toml"), "").unwrap();
std::fs::write(dir.path().join("fnox.local.toml"), "").unwrap();
let result = super::find_local_config(dir.path(), None);
assert_eq!(result, dir.path().join("fnox.toml"));
}
#[test]
fn test_find_local_config_only_dotfile() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join(".fnox.toml"), "").unwrap();
let result = super::find_local_config(dir.path(), None);
assert_eq!(result, dir.path().join(".fnox.toml"));
}
#[test]
fn test_find_local_config_profile() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("fnox.staging.toml"), "").unwrap();
let result = super::find_local_config(dir.path(), Some("staging"));
assert_eq!(result, dir.path().join("fnox.staging.toml"));
}
#[test]
fn test_find_local_config_profile_with_base() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("fnox.toml"), "").unwrap();
std::fs::write(dir.path().join("fnox.staging.toml"), "").unwrap();
let result = super::find_local_config(dir.path(), Some("staging"));
assert_eq!(result, dir.path().join("fnox.staging.toml"));
}
#[test]
fn test_find_local_config_default_profile_with_base() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("fnox.toml"), "").unwrap();
std::fs::write(dir.path().join("fnox.local.toml"), "").unwrap();
let result = super::find_local_config(dir.path(), Some("default"));
assert_eq!(result, dir.path().join("fnox.toml"));
}
#[test]
fn test_find_local_config_profile_only_base_exists() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("fnox.toml"), "").unwrap();
let result = super::find_local_config(dir.path(), Some("staging"));
assert_eq!(result, dir.path().join("fnox.toml"));
}
#[test]
fn test_find_local_config_profile_skips_local_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("fnox.local.toml"), "").unwrap();
let result = super::find_local_config(dir.path(), Some("staging"));
assert_eq!(result, dir.path().join("fnox.toml"));
}
#[test]
fn test_find_local_config_no_profile_uses_local_file() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("fnox.local.toml"), "").unwrap();
let result = super::find_local_config(dir.path(), None);
assert_eq!(result, dir.path().join("fnox.local.toml"));
}
#[test]
fn filter_secrets_none_allowlist_returns_all() {
let cfg = McpConfig::default(); let mut m = IndexMap::new();
m.insert("A".to_string(), SecretConfig::new());
m.insert("B".to_string(), SecretConfig::new());
let result = cfg.filter_secrets(m.clone());
assert_eq!(
result.keys().collect::<Vec<_>>(),
m.keys().collect::<Vec<_>>()
);
}
#[test]
fn filter_secrets_empty_allowlist_returns_empty() {
let cfg = McpConfig {
secrets: Some(vec![]),
..Default::default()
};
let mut m = IndexMap::new();
m.insert("A".to_string(), SecretConfig::new());
assert!(cfg.filter_secrets(m).is_empty());
}
#[test]
fn filter_secrets_subset() {
let cfg = McpConfig {
secrets: Some(vec!["A".into()]),
..Default::default()
};
let mut m = IndexMap::new();
m.insert("A".to_string(), SecretConfig::new());
m.insert("B".to_string(), SecretConfig::new());
let result = cfg.filter_secrets(m);
assert!(result.contains_key("A"));
assert!(!result.contains_key("B"));
}
#[test]
fn filter_secrets_unknown_allowlist_entry_ignored() {
let cfg = McpConfig {
secrets: Some(vec!["A".into(), "NONEXISTENT".into()]),
..Default::default()
};
let mut m = IndexMap::new();
m.insert("A".to_string(), SecretConfig::new());
let result = cfg.filter_secrets(m);
assert_eq!(result.len(), 1);
assert!(result.contains_key("A"));
}
#[test]
fn mcp_secrets_overlay_replaces_base_not_appends() {
let base = Config {
mcp: Some(McpConfig {
secrets: Some(vec!["A".into()]),
..Default::default()
}),
..Config::new()
};
let overlay = Config {
mcp: Some(McpConfig {
secrets: Some(vec!["B".into()]),
..Default::default()
}),
..Config::new()
};
let merged = Config::merge_configs(base, overlay).unwrap();
assert_eq!(
merged.mcp.unwrap().secrets,
Some(vec!["B".into()]),
"overlay must replace, not append, the base allowlist"
);
}
#[test]
fn mcp_secrets_overlay_without_secrets_preserves_base() {
let base = Config {
mcp: Some(McpConfig {
secrets: Some(vec!["A".into()]),
..Default::default()
}),
..Config::new()
};
let overlay = Config {
mcp: Some(McpConfig {
..Default::default()
}),
..Config::new()
};
let merged = Config::merge_configs(base, overlay).unwrap();
assert_eq!(merged.mcp.unwrap().secrets, Some(vec!["A".into()]));
}
#[test]
fn test_for_raw_resolve_strips_post_processing_fields() {
let mut secret = SecretConfig::new();
secret.set_provider(Some("plain".to_string()));
secret.set_value(Some(r#"{"user":"admin"}"#.to_string()));
secret.default = Some("fallback".to_string());
secret.json_path = Some("user".to_string());
secret.line = Some(2);
secret.sync = Some(SyncConfig {
provider: "age".to_string(),
value: "encrypted-blob".to_string(),
});
let raw = secret.for_raw_resolve();
assert!(raw.default.is_none());
assert!(raw.json_path.is_none());
assert!(raw.line.is_none());
assert!(raw.sync.is_none());
}
#[test]
fn test_secret_config_line_roundtrip() {
let toml_input = r#"
[secrets]
USERNAME = { provider = "pass", value = "master", line = 2 }
"#;
let parsed: Config = toml_edit::de::from_str(toml_input).unwrap();
let secret = parsed.secrets.get("USERNAME").unwrap();
assert_eq!(secret.line, Some(2));
let inline = secret.to_inline_table();
let rendered = inline.to_string();
assert!(
rendered.contains("line = 2"),
"expected serialized output to contain `line = 2`, got: {rendered}"
);
}
#[test]
fn test_for_raw_resolve_preserves_non_post_processing_fields() {
let mut secret = SecretConfig::new();
secret.set_provider(Some("plain".to_string()));
secret.set_value(Some("my-secret".to_string()));
secret.description = Some("A test secret".to_string());
secret.if_missing = Some(IfMissing::Warn);
secret.env = false;
secret.as_file = true;
secret.source_path = Some(PathBuf::from("/tmp/fnox.toml"));
secret.source_is_profile = true;
secret.default = Some("default-val".to_string());
secret.json_path = Some("key".to_string());
secret.sync = Some(SyncConfig {
provider: "age".to_string(),
value: "blob".to_string(),
});
let raw = secret.for_raw_resolve();
assert_eq!(raw.provider(), Some("plain"));
assert_eq!(raw.value(), Some("my-secret"));
assert_eq!(raw.description.as_deref(), Some("A test secret"));
assert_eq!(raw.if_missing, Some(IfMissing::Warn));
assert!(!raw.env);
assert!(raw.as_file);
assert_eq!(
raw.source_path.as_deref(),
Some(Path::new("/tmp/fnox.toml"))
);
assert!(raw.source_is_profile);
}
#[test]
fn test_for_raw_resolve_with_no_post_processing_fields() {
let mut secret = SecretConfig::new();
secret.set_provider(Some("plain".to_string()));
secret.set_value(Some("simple-value".to_string()));
let raw = secret.for_raw_resolve();
assert_eq!(raw.provider(), Some("plain"));
assert_eq!(raw.value(), Some("simple-value"));
assert!(raw.default.is_none());
assert!(raw.json_path.is_none());
assert!(raw.sync.is_none());
}
}