use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Config {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active: Option<String>,
#[serde(default)]
pub profiles: BTreeMap<String, Profile>,
#[serde(default, skip_serializing_if = "rig_config_is_empty")]
pub rig: RigConfig,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub rigs: BTreeMap<String, RigEntry>,
#[serde(
default,
skip_serializing_if = "UiConfig::is_default",
deserialize_with = "lenient_ui"
)]
pub ui: UiConfig,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct UiConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub theme: Option<String>,
}
impl UiConfig {
pub fn is_default(ui: &UiConfig) -> bool {
*ui == UiConfig::default()
}
}
fn lenient_u64<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<u64>, D::Error> {
let value = toml::Value::deserialize(d)?;
match value {
toml::Value::Integer(v) if v >= 0 => Ok(Some(v as u64)),
other => {
tracing::warn!(
value = %other,
"poll_interval_secs must be a non-negative integer — ignoring (default cadence in use)"
);
Ok(None)
}
}
}
fn lenient_ui<'de, D: serde::Deserializer<'de>>(d: D) -> Result<UiConfig, D::Error> {
let value = toml::Value::deserialize(d)?;
match UiConfig::deserialize(value) {
Ok(ui) => Ok(ui),
Err(_) => {
tracing::warn!("[ui] is not a valid table — using defaults");
Ok(UiConfig::default())
}
}
}
fn rig_config_is_empty(rig: &RigConfig) -> bool {
rig.default.is_none()
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct RigConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RigEntry {
pub compose_file: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Profile {
pub url: url::Url,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default = "default_ssl_verify")]
pub ssl_verify: bool,
#[serde(default)]
pub auth: AuthRef,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub webdev_secret: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "lenient_u64"
)]
pub poll_interval_secs: Option<u64>,
}
fn default_ssl_verify() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum AuthRef {
TokenEnv {
token_env: String,
},
Keyring {
keyring: String,
},
Basic {
user_env: String,
password_env: String,
},
}
impl AuthRef {
pub fn kind(&self) -> &'static str {
match self {
Self::TokenEnv { .. } => "token_env",
Self::Keyring { .. } => "keyring",
Self::Basic { .. } => "basic",
}
}
}
impl Default for AuthRef {
fn default() -> Self {
Self::TokenEnv {
token_env: "IGNITION_TOKEN".to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::{AuthRef, Config};
#[test]
fn auth_ref_untagged_round_trip() {
let toml = r#"
active = "dev"
[profiles.dev]
url = "http://localhost:9088/"
label = "Dev rig"
auth = { token_env = "IGNITION_TOKEN" }
[profiles.prod]
url = "https://gw.example.com:8443/"
auth = { keyring = "profile:prod" }
[profiles.rig]
url = "http://10.0.0.5:9088/"
ssl_verify = false
auth = { user_env = "IGNITION_USER", password_env = "IGNITION_PASSWORD" }
"#;
let config: Config = toml::from_str(toml).expect("parse");
assert_eq!(config.active.as_deref(), Some("dev"));
assert_eq!(config.profiles.len(), 3);
let dev = &config.profiles["dev"];
assert_eq!(dev.label.as_deref(), Some("Dev rig"));
assert!(dev.ssl_verify, "ssl_verify defaults true");
assert_eq!(
dev.auth,
AuthRef::TokenEnv {
token_env: "IGNITION_TOKEN".into()
}
);
assert_eq!(dev.auth.kind(), "token_env");
let prod = &config.profiles["prod"];
assert_eq!(prod.label, None, "label absent when unset");
assert_eq!(
prod.auth,
AuthRef::Keyring {
keyring: "profile:prod".into()
}
);
assert_eq!(prod.auth.kind(), "keyring");
let rig = &config.profiles["rig"];
assert!(!rig.ssl_verify, "ssl_verify = false honored");
assert_eq!(
rig.auth,
AuthRef::Basic {
user_env: "IGNITION_USER".into(),
password_env: "IGNITION_PASSWORD".into(),
}
);
assert_eq!(rig.auth.kind(), "basic");
let reserialized = toml::to_string(&config).expect("serialize");
assert!(reserialized.contains("label = \"Dev rig\""));
assert!(!reserialized.contains("[profiles.prod]\nlabel"));
let back: Config = toml::from_str(&reserialized).expect("re-parse");
assert_eq!(back, config);
}
#[test]
fn auth_defaults_to_generic_token_env() {
let toml = "[profiles.dev]\nurl = \"http://localhost:9088/\"\n";
let config: Config = toml::from_str(toml).expect("parse");
assert_eq!(
config.profiles["dev"].auth,
AuthRef::TokenEnv {
token_env: "IGNITION_TOKEN".into()
}
);
}
#[test]
fn ui_and_poll_interval_round_trip() {
let toml = r#"
active = "dev"
[ui]
theme = "dark"
[profiles.dev]
url = "http://localhost:9088/"
poll_interval_secs = 10
"#;
let config: Config = toml::from_str(toml).expect("parse");
assert_eq!(config.ui.theme.as_deref(), Some("dark"));
assert_eq!(config.profiles["dev"].poll_interval_secs, Some(10));
let reserialized = toml::to_string(&config).expect("serialize");
let back: Config = toml::from_str(&reserialized).expect("re-parse");
assert_eq!(back, config, "round trip must be lossless");
}
#[test]
fn legacy_config_serializes_without_new_keys() {
let toml = "[profiles.dev]\nurl = \"http://localhost:9088/\"\n";
let config: Config = toml::from_str(toml).expect("parse");
let out = toml::to_string(&config).expect("serialize");
assert!(
!out.contains("ui") && !out.contains("poll_interval_secs"),
"new keys must stay off legacy-shaped configs: {out}"
);
}
#[test]
fn lenient_degrade_on_bad_typed_new_keys() {
let toml = r#"
[ui]
theme = "dark"
[profiles.dev]
url = "http://localhost:9088/"
poll_interval_secs = "banana"
"#;
let config: Config = toml::from_str(toml).expect("typo'd new key must not fail the load");
assert_eq!(
config.profiles["dev"].poll_interval_secs, None,
"bad poll_interval_secs degrades to None"
);
let toml = "ui = 42\n[profiles.dev]\nurl = \"http://localhost:9088/\"\n";
let config: Config = toml::from_str(toml).expect("bad [ui] shape must not fail the load");
assert_eq!(
config.ui,
super::UiConfig::default(),
"non-table [ui] degrades to the default"
);
}
#[test]
fn ui_table_with_unknown_keys_still_loads() {
let toml = r#"
[ui]
theme = "dark"
future_key = "whatever"
[profiles.dev]
url = "http://localhost:9088/"
"#;
let config: Config = toml::from_str(toml).expect("parse");
assert_eq!(config.ui.theme.as_deref(), Some("dark"));
}
#[test]
fn poll_interval_zero_parses_leniently() {
let toml = "[profiles.dev]\nurl = \"http://localhost:9088/\"\npoll_interval_secs = 0\n";
let config: Config = toml::from_str(toml).expect("parse");
assert_eq!(config.profiles["dev"].poll_interval_secs, Some(0));
}
}