use std::path::{Path, PathBuf};
use crate::bootstrap::VaultArgs;
use zeph_config::VaultBackend;
use zeph_core::config::Config;
#[derive(Debug)]
pub struct ConfigLoadError {
path: PathBuf,
source: zeph_config::ConfigError,
}
impl std::fmt::Display for ConfigLoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "failed to parse config at {}", self.path.display())
}
}
impl std::error::Error for ConfigLoadError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.source)
}
}
pub fn load_config_or_default(path: &Path) -> Result<Config, ConfigLoadError> {
if !path.exists() {
eprintln!(
"Config file not found at {} — running with defaults. \
Run 'zeph init' to create one.",
path.display()
);
return Ok(Config::default());
}
zeph_config::Config::load(path).map_err(|source| ConfigLoadError {
path: path.to_owned(),
source,
})
}
pub fn resolve_config_path(cli_override: Option<&Path>) -> PathBuf {
let cwd_default = Path::new("config/default.toml");
resolve_config_path_impl(
cli_override,
|name| std::env::var(name).ok(),
cwd_default.exists(),
)
}
fn resolve_config_path_impl(
cli_override: Option<&Path>,
get_env: impl Fn(&str) -> Option<String>,
cwd_default_exists: bool,
) -> PathBuf {
if let Some(path) = cli_override {
tracing::debug!("config resolved via CLI flag: {}", path.display());
return path.to_owned();
}
if let Some(val) = get_env("ZEPH_CONFIG") {
let path = PathBuf::from(&val);
tracing::debug!(
"config resolved via ZEPH_CONFIG env var: {}",
path.display()
);
return path;
}
if cwd_default_exists {
tracing::debug!("config resolved via CWD default: config/default.toml");
return PathBuf::from("config/default.toml");
}
let xdg = dirs::config_dir()
.unwrap_or_else(|| {
get_env("HOME")
.map_or_else(|| PathBuf::from("~"), PathBuf::from)
.join(".config")
})
.join("zeph")
.join("config.toml");
tracing::debug!("config resolved via XDG fallback: {}", xdg.display());
xdg
}
fn parse_backend_str(s: &str) -> Result<VaultBackend, String> {
match s {
"env" => Ok(VaultBackend::Env),
"age" => Ok(VaultBackend::Age),
"keyring" => Ok(VaultBackend::Keyring),
other => Err(format!(
"unknown vault backend '{other}': expected one of \"env\", \"age\", \"keyring\""
)),
}
}
pub fn parse_vault_args(
config: &Config,
cli_backend: Option<&str>,
cli_key_path: Option<&Path>,
cli_vault_path: Option<&Path>,
) -> Result<VaultArgs, String> {
let env_backend = std::env::var("ZEPH_VAULT_BACKEND").ok();
let backend = match cli_backend.or(env_backend.as_deref()) {
Some(s) => parse_backend_str(s)?,
None => config.vault.backend,
};
let env_key = std::env::var("ZEPH_VAULT_KEY").ok();
let default_dir = zeph_core::vault::default_vault_dir();
let key_path = cli_key_path
.map(|p| p.to_string_lossy().into_owned())
.or(env_key)
.or_else(|| {
if backend == VaultBackend::Age {
Some(
default_dir
.join("vault-key.txt")
.to_string_lossy()
.into_owned(),
)
} else {
None
}
});
let env_vault = std::env::var("ZEPH_VAULT_PATH").ok();
let vault_path = cli_vault_path
.map(|p| p.to_string_lossy().into_owned())
.or(env_vault)
.or_else(|| {
if backend == VaultBackend::Age {
Some(
default_dir
.join("secrets.age")
.to_string_lossy()
.into_owned(),
)
} else {
None
}
});
Ok(VaultArgs {
backend,
key_path,
vault_path,
})
}
pub fn resolve_vault_paths(
config: &Config,
cli_backend: Option<&str>,
cli_key_path: Option<&Path>,
cli_vault_path: Option<&Path>,
) -> (PathBuf, PathBuf) {
let default_dir = zeph_core::vault::default_vault_dir();
let (resolved_key, resolved_vault) =
parse_vault_args(config, cli_backend, cli_key_path, cli_vault_path)
.map(|va| (va.key_path, va.vault_path))
.unwrap_or_default();
(
resolved_key.map_or_else(|| default_dir.join("vault-key.txt"), PathBuf::from),
resolved_vault.map_or_else(|| default_dir.join("secrets.age"), PathBuf::from),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn no_env(_: &str) -> Option<String> {
None
}
#[test]
fn cli_override_takes_precedence() {
let path = Path::new("/custom/config.toml");
let result = resolve_config_path_impl(Some(path), no_env, false);
assert_eq!(result, PathBuf::from("/custom/config.toml"));
}
#[test]
fn env_var_used_when_no_cli() {
let result = resolve_config_path_impl(
None,
|name| {
if name == "ZEPH_CONFIG" {
Some("/env/config.toml".to_owned())
} else {
None
}
},
false,
);
assert_eq!(result, PathBuf::from("/env/config.toml"));
}
#[test]
fn cwd_default_returned_when_exists() {
let result = resolve_config_path_impl(None, no_env, true);
assert_eq!(result, PathBuf::from("config/default.toml"));
}
#[test]
fn xdg_fallback_path_constructed() {
let result = resolve_config_path_impl(None, no_env, false);
assert!(
result.ends_with("zeph/config.toml"),
"unexpected path: {}",
result.display()
);
}
#[test]
fn xdg_fallback_matches_wizard_default() {
let runtime_path = resolve_config_path_impl(None, no_env, false);
let wizard_path = crate::init::wizard_default_config_path();
assert_eq!(
runtime_path, wizard_path,
"init wizard default path and runtime XDG fallback diverged"
);
}
#[test]
fn load_config_or_default_missing_file_returns_defaults() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let path = tmp.path().to_owned();
drop(tmp);
assert!(!path.exists(), "temp file must be gone before the test");
let cfg = load_config_or_default(&path).expect("missing file falls back to defaults");
let default_cfg = zeph_core::config::Config::default();
assert_eq!(cfg.agent.name, default_cfg.agent.name);
}
#[test]
fn load_config_or_default_parse_failure_returns_err() {
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), "this is not valid = = toml").unwrap();
let err = load_config_or_default(tmp.path())
.expect_err("malformed TOML must be reported, not silently defaulted");
assert!(
err.to_string().contains("failed to parse config at"),
"unexpected error message: {err}"
);
}
}