pub mod profile;
pub mod secret;
pub use profile::{AuthRef, Config, Profile, RigConfig, RigEntry, UiConfig};
pub use secret::{
BasicEnvStore, Credential, EnvStore, KeyringStore, Secret, SecretStore, resolve_secret,
};
use std::path::{Path, PathBuf};
use crate::error::CoreError;
#[cfg(test)]
pub(crate) static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
pub fn config_path() -> PathBuf {
std::env::var_os("IGNITION_CLI_CONFIG")
.map(PathBuf::from)
.unwrap_or_else(|| {
let dirs = directories::ProjectDirs::from("", "", "ignition-cli")
.expect("no home directory discoverable");
dirs.config_dir().join("config.toml")
})
}
pub fn load(path: &Path) -> Result<Config, CoreError> {
load_inner(path, true)
}
pub fn load_for_tui(path: &Path) -> Result<Config, CoreError> {
load_inner(path, false)
}
fn load_inner(path: &Path, strict_clamp: bool) -> Result<Config, CoreError> {
let raw = match std::fs::read_to_string(path) {
Ok(raw) => raw,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Ok(Config::default());
}
Err(err) => {
return Err(CoreError::ConfigInvalid {
reason: format!("cannot read {}: {err}", path.display()),
});
}
};
if raw.trim().is_empty() {
return Ok(Config::default());
}
warn_unknown_keys(&raw);
let mut config: Config = toml::from_str(&raw).map_err(|err| CoreError::ConfigInvalid {
reason: format!("{}: {err}", path.display()),
})?;
if strict_clamp {
validate(&config)?;
} else {
degrade_clamp_violations(&mut config);
}
Ok(config)
}
fn validate(config: &Config) -> Result<(), CoreError> {
for (name, profile) in &config.profiles {
if profile.poll_interval_secs == Some(0) {
return Err(CoreError::PollIntervalTooSmall {
profile: name.clone(),
});
}
}
Ok(())
}
fn degrade_clamp_violations(config: &mut Config) {
for (name, profile) in &mut config.profiles {
if profile.poll_interval_secs == Some(0) {
profile.poll_interval_secs = None;
tracing::warn!(
slug = "poll_interval_too_small",
profile = %name,
"poll_interval_secs must be >= 1 (sub-second polling refused) — using the default cadence"
);
}
}
}
const KNOWN_TOP_LEVEL: &[&str] = &["active", "profiles", "rig", "rigs", "ui"];
const KNOWN_PROFILE_KEYS: &[&str] = &[
"url",
"label",
"ssl_verify",
"auth",
"webdev_secret",
"poll_interval_secs",
];
const KNOWN_AUTH_KEYS: &[&str] = &["token_env", "keyring", "user_env", "password_env"];
fn warn_unknown_keys(raw: &str) {
let Ok(table) = raw.parse::<toml::Table>() else {
return;
};
for (key, value) in &table {
if !KNOWN_TOP_LEVEL.contains(&key.as_str()) {
tracing::warn!(key = %key, "unknown config key (ignored)");
}
if key != "profiles" {
continue;
}
let Some(profiles) = value.as_table() else {
continue;
};
for (name, profile_value) in profiles {
let Some(profile_table) = profile_value.as_table() else {
continue;
};
for (profile_key, auth_value) in profile_table {
if !KNOWN_PROFILE_KEYS.contains(&profile_key.as_str()) {
tracing::warn!(profile = %name, key = %profile_key, "unknown profile key (ignored)");
}
if profile_key == "auth"
&& let Some(auth_table) = auth_value.as_table()
{
for auth_key in auth_table.keys() {
if !KNOWN_AUTH_KEYS.contains(&auth_key.as_str()) {
tracing::warn!(profile = %name, key = auth_key, "unknown auth key (ignored)");
}
}
}
}
}
}
}
pub fn save(path: &Path, config: &Config) -> Result<(), CoreError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|err| CoreError::ConfigInvalid {
reason: format!("cannot create {}: {err}", parent.display()),
})?;
}
let contents = toml::to_string_pretty(config).map_err(|err| CoreError::ConfigInvalid {
reason: format!("cannot serialize config: {err}"),
})?;
std::fs::write(path, contents).map_err(|err| CoreError::ConfigInvalid {
reason: format!("cannot write {}: {err}", path.display()),
})?;
enforce_0600(path)
}
#[cfg(unix)]
fn enforce_0600(path: &Path) -> Result<(), CoreError> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|err| {
CoreError::ConfigInvalid {
reason: format!("cannot set 0600 on {}: {err}", path.display()),
}
})
}
#[cfg(not(unix))]
fn enforce_0600(_path: &Path) -> Result<(), CoreError> {
Ok(())
}
pub fn apply_env_overlay(config: &mut Config, selected_profile: Option<&str>) {
let Some(name) = selected_profile else { return };
let Ok(url_string) = std::env::var("IGNITION_URL") else {
return;
};
if url_string.is_empty() {
return;
}
let Ok(url) = url::Url::parse(&url_string) else {
tracing::warn!(url = %url_string, "IGNITION_URL is not a valid URL; ignoring");
return;
};
if let Some(profile) = config.profiles.get_mut(name) {
profile.url = url;
}
}
pub fn resolve_selection(
config: &Config,
flag: Option<&str>,
) -> Result<Option<(String, Profile)>, CoreError> {
let name = match flag.map(str::to_owned).or_else(|| config.active.clone()) {
Some(name) => name,
None => return Ok(None),
};
match config.profiles.get(&name) {
Some(profile) => Ok(Some((name, profile.clone()))),
None => Err(CoreError::ProfileNotFound {
name,
known: config.profiles.keys().cloned().collect(),
}),
}
}
#[cfg(test)]
mod tests {
use super::{
Config, Profile, apply_env_overlay, config_path, load, load_for_tui, resolve_selection,
save,
};
use crate::config::AuthRef;
use crate::config::ENV_LOCK;
use crate::error::CoreError;
use std::path::PathBuf;
fn temp_config_path() -> (tempfile::TempDir, PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("config.toml");
(dir, path)
}
fn sample_config() -> Config {
let mut config = Config {
active: Some("dev".into()),
..Config::default()
};
config.profiles.insert(
"dev".into(),
Profile {
url: "http://localhost:9088/".parse().expect("url"),
label: Some("Dev rig".into()),
ssl_verify: true,
auth: AuthRef::TokenEnv {
token_env: "IGNITION_TOKEN".into(),
},
webdev_secret: None,
poll_interval_secs: None,
},
);
config.profiles.insert(
"prod".into(),
Profile {
url: "https://gw.example.com:8443/".parse().expect("url"),
label: None,
ssl_verify: true,
auth: AuthRef::Keyring {
keyring: "profile:prod".into(),
},
webdev_secret: None,
poll_interval_secs: None,
},
);
config
}
#[test]
fn round_trip_save_load() {
let (_dir, path) = temp_config_path();
let config = sample_config();
save(&path, &config).expect("save");
let reloaded = load(&path).expect("load");
assert_eq!(reloaded, config, "round trip must be lossless");
let raw = std::fs::read_to_string(&path).expect("read raw");
assert!(raw.contains("label = \"Dev rig\""));
let prod_section = raw
.split("[profiles.prod]")
.nth(1)
.expect("prod section serialized");
assert!(
!prod_section.contains("label"),
"unset label must not be serialized: {prod_section}",
);
}
#[test]
#[cfg(unix)]
fn save_enforces_0600_and_creates_parents() {
use std::os::unix::fs::PermissionsExt;
let (_dir, path) = temp_config_path();
let nested = path.parent().unwrap().join("nested/deeper/config.toml");
save(&nested, &sample_config()).expect("save creates parent dirs");
let mode = std::fs::metadata(&nested)
.expect("metadata")
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600, "fresh config must be 0600");
std::fs::set_permissions(&nested, std::fs::Permissions::from_mode(0o644)).expect("loosen");
save(&nested, &sample_config()).expect("save again");
let mode = std::fs::metadata(&nested)
.expect("metadata")
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600, "overwrite must re-assert 0600");
}
#[test]
fn unknown_keys_warn_but_do_not_fail() {
let (_dir, path) = temp_config_path();
std::fs::write(
&path,
r#"
future_top_level = "whatever"
active = "dev"
[profiles.dev]
url = "http://localhost:9088/"
future_profile_key = 42
[profiles.dev.auth]
token_env = "IGNITION_TOKEN"
future_auth_key = "x"
"#,
)
.expect("write");
let config = load(&path).expect("unknown keys must not fail the load");
assert_eq!(config.active.as_deref(), Some("dev"));
assert!(config.profiles.contains_key("dev"));
}
#[test]
fn new_schema_keys_are_warn_silent() {
assert!(
super::KNOWN_TOP_LEVEL.contains(&"ui"),
"KNOWN_TOP_LEVEL must carry \"ui\""
);
assert!(
super::KNOWN_PROFILE_KEYS.contains(&"poll_interval_secs"),
"KNOWN_PROFILE_KEYS must carry \"poll_interval_secs\""
);
let (_dir, path) = temp_config_path();
std::fs::write(
&path,
r#"
[ui]
theme = "dark"
[profiles.dev]
url = "http://localhost:9088/"
poll_interval_secs = 10
"#,
)
.expect("write");
let config = load(&path).expect("new keys must not fail the load");
assert_eq!(config.ui.theme.as_deref(), Some("dark"));
assert_eq!(config.profiles["dev"].poll_interval_secs, Some(10));
}
#[test]
fn poll_interval_zero_is_refused() {
let (_dir, path) = temp_config_path();
std::fs::write(
&path,
"[profiles.dev]\nurl = \"http://localhost:9088/\"\npoll_interval_secs = 0\n",
)
.expect("write");
let err = load(&path).expect_err("0 must be refused");
assert_eq!(err.code(), "poll_interval_too_small");
assert_eq!(err.exit_code(), 3, "config class — no new exit code");
let message = err.to_string();
assert!(
message.contains("dev") && message.contains("sub-second"),
"refusal must name the profile + the rule: {message}"
);
let hint = err.hint().expect("hint required");
assert!(
hint.contains("[profiles.dev]") && hint.contains("poll_interval_secs"),
"hint must point at the profile key: {hint}"
);
}
#[test]
fn poll_interval_one_is_the_floor() {
let (_dir, path) = temp_config_path();
std::fs::write(
&path,
"[profiles.dev]\nurl = \"http://localhost:9088/\"\npoll_interval_secs = 1\n",
)
.expect("write");
let config = load(&path).expect("1 is the floor — must load");
assert_eq!(config.profiles["dev"].poll_interval_secs, Some(1));
}
#[test]
fn load_for_tui_degrades_clamp_violation() {
let (_dir, path) = temp_config_path();
std::fs::write(
&path,
"[profiles.dev]\nurl = \"http://localhost:9088/\"\npoll_interval_secs = 0\n",
)
.expect("write");
let config = load_for_tui(&path).expect("the TUI degrades the clamp instead of refusing");
assert_eq!(
config.profiles["dev"].poll_interval_secs, None,
"sub-second value substituted with the default cadence"
);
}
#[test]
fn load_for_tui_still_refuses_broken_profile_url() {
let (_dir, path) = temp_config_path();
std::fs::write(
&path,
"[profiles.dev]\nurl = \"not a url at all\"\npoll_interval_secs = 5\n",
)
.expect("write");
let err = load_for_tui(&path).expect_err("broken profile url is fatal");
assert_eq!(err.exit_code(), 3, "config_invalid class");
assert_eq!(err.code(), "config_invalid");
}
#[test]
fn load_for_tui_still_refuses_garbage_toml() {
let (_dir, path) = temp_config_path();
std::fs::write(&path, "this is ][ not toml\n").expect("write");
let err = load_for_tui(&path).expect_err("garbage toml is fatal");
assert_eq!(err.exit_code(), 3);
assert_eq!(err.code(), "config_invalid");
}
#[test]
fn missing_file_and_no_selection_resolve_none() {
let (_dir, path) = temp_config_path();
assert!(!path.exists(), "fixture sanity");
let config = load(&path).expect("missing file is not an error");
assert_eq!(config, Config::default());
let selection =
resolve_selection(&config, None).expect("no active + no flag is not an error");
assert!(selection.is_none());
}
#[test]
fn unknown_profile_lists_known() {
let config = sample_config();
let err = resolve_selection(&config, Some("nope")).expect_err("unknown profile errors");
match err {
CoreError::ProfileNotFound {
ref name,
ref known,
} => {
assert_eq!(name, "nope");
assert_eq!(known, &vec!["dev".to_string(), "prod".to_string()]);
}
other => panic!("wrong error class: {other}"),
}
assert_eq!(err.exit_code(), 3);
let hint = err.hint().expect("hint");
assert!(
hint.contains("dev") && hint.contains("prod"),
"hint names knowns: {hint}"
);
}
#[test]
fn selection_flag_beats_active() {
let config = sample_config(); let (name, profile) = resolve_selection(&config, Some("prod"))
.expect("flag selects prod")
.expect("some");
assert_eq!(name, "prod");
assert_eq!(
profile.auth,
AuthRef::Keyring {
keyring: "profile:prod".into()
}
);
}
#[test]
fn env_overlay_overrides_selected_profile_url() {
let _lock = ENV_LOCK.lock().expect("env lock");
unsafe { std::env::set_var("IGNITION_URL", "http://override.example:7000") };
let mut config = sample_config();
apply_env_overlay(&mut config, Some("dev"));
assert_eq!(
config.profiles["dev"].url.as_str(),
"http://override.example:7000/",
"selected profile URL overridden",
);
assert_eq!(
config.profiles["prod"].url.as_str(),
"https://gw.example.com:8443/",
"other profiles untouched",
);
let mut config = sample_config();
apply_env_overlay(&mut config, None);
assert_eq!(
config.profiles["dev"].url.as_str(),
"http://localhost:9088/",
"no selected profile → overlay is a no-op",
);
unsafe { std::env::remove_var("IGNITION_URL") };
}
#[test]
fn config_path_env_override_first() {
let _lock = ENV_LOCK.lock().expect("env lock");
let dir = tempfile::tempdir().expect("tempdir");
let override_path = dir.path().join("my-config.toml");
unsafe { std::env::set_var("IGNITION_CLI_CONFIG", &override_path) };
assert_eq!(config_path(), override_path, "env override wins");
unsafe { std::env::remove_var("IGNITION_CLI_CONFIG") };
assert!(
config_path().ends_with("config.toml"),
"platform fallback lands on config.toml: {}",
config_path().display(),
);
}
}