Skip to main content

ignition_core/config/
profile.rs

1//! Config + Profile serde structs — the on-disk shape of `config.toml`.
2//!
3//! Secrets are REFERENCES (env var names, keyring user strings), never
4//! values: no field on [`Profile`] or [`AuthRef`] ever holds a secret string
5//! (CORE-02). No `deny_unknown_fields` anywhere (research Pitfall 7 —
6//! forward compat; unknown keys warn at load time instead, see
7//! [`super::load`]).
8
9use std::collections::BTreeMap;
10
11use serde::{Deserialize, Serialize};
12
13/// The whole `config.toml`. `profiles` is a `BTreeMap` so `profile list`
14/// output — and therefore every golden — is deterministic by name.
15#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
16pub struct Config {
17    /// The profile commands operate on by default.
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub active: Option<String>,
20    /// Named gateway profiles (`[profiles.NAME]` tables).
21    #[serde(default)]
22    pub profiles: BTreeMap<String, Profile>,
23    /// Rig family defaults (`[rig]` table — just the default rig name
24    /// today; 04-01).
25    #[serde(default, skip_serializing_if = "rig_config_is_empty")]
26    pub rig: RigConfig,
27    /// Named compose rigs (`[rigs.NAME]` tables — 04-01). Omitted from
28    /// serialization when empty so profile-only configs keep their exact
29    /// on-disk shape (the save goldens).
30    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
31    pub rigs: BTreeMap<String, RigEntry>,
32    /// UI preferences (`[ui]` table — TUIX-05 plumbing). PLUMBING ONLY:
33    /// `theme` is carried on the config, never rendered (rendering is
34    /// Phase 12, TUIX-03/04). Omitted from serialization at defaults so
35    /// legacy configs round-trip byte-identically; a wrong-shaped `[ui]`
36    /// degrades to the default with a warning (see [`lenient_ui`]).
37    #[serde(
38        default,
39        skip_serializing_if = "UiConfig::is_default",
40        deserialize_with = "lenient_ui"
41    )]
42    pub ui: UiConfig,
43}
44
45/// The `[ui]` table: TUI preference keys (TUIX-05 plumbing).
46#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
47pub struct UiConfig {
48    /// Preferred UI theme name (e.g. `"dark"`). `None` = the built-in
49    /// default. Carried only — nothing consumes it until Phase 12.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub theme: Option<String>,
52}
53
54impl UiConfig {
55    /// True when nothing is set — keeps `[ui]` out of configs that never
56    /// carried one (the `rig_config_is_empty` precedent).
57    pub fn is_default(ui: &UiConfig) -> bool {
58        *ui == UiConfig::default()
59    }
60}
61
62/// Lenient `poll_interval_secs` extraction (TUIX-05): an integer loads
63/// verbatim — INCLUDING 0, which the load-time clamp (see
64/// [`super::load`]) refuses; anything else (string, float, negative,
65/// table) warns and degrades to `None` (default cadence). A typo in the
66/// NEW key never fails the load.
67fn lenient_u64<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<u64>, D::Error> {
68    let value = toml::Value::deserialize(d)?;
69    match value {
70        toml::Value::Integer(v) if v >= 0 => Ok(Some(v as u64)),
71        other => {
72            tracing::warn!(
73                value = %other,
74                "poll_interval_secs must be a non-negative integer — ignoring (default cadence in use)"
75            );
76            Ok(None)
77        }
78    }
79}
80
81/// Lenient `[ui]` extraction (TUIX-05): a well-shaped `[ui]` table loads;
82/// anything else (a bare scalar, a mistyped member) warns and degrades to
83/// the default. Unknown keys INSIDE a valid table are simply ignored (no
84/// `deny_unknown_fields`, ever — Pitfall 7 forward compat).
85fn lenient_ui<'de, D: serde::Deserializer<'de>>(d: D) -> Result<UiConfig, D::Error> {
86    let value = toml::Value::deserialize(d)?;
87    match UiConfig::deserialize(value) {
88        Ok(ui) => Ok(ui),
89        Err(_) => {
90            tracing::warn!("[ui] is not a valid table — using defaults");
91            Ok(UiConfig::default())
92        }
93    }
94}
95
96/// True when the `[rig]` block carries nothing worth serializing —
97/// keeps `[rig]` out of profile-only configs entirely.
98fn rig_config_is_empty(rig: &RigConfig) -> bool {
99    rig.default.is_none()
100}
101
102/// The `[rig]` table: rig-family defaults (04-01).
103#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
104pub struct RigConfig {
105    /// Name of the `[rigs.*]` entry `ign rig` targets when no
106    /// `--rig`/`IGNITION_RIG` names one.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub default: Option<String>,
109}
110
111/// One named compose rig (`[rigs.NAME]`, 04-01). References only —
112/// never secrets (the compose file itself owns those).
113#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
114pub struct RigEntry {
115    /// Path to the rig's compose file (`~` and `${VAR}` expanded at use
116    /// time — manual expansion, no new dependency).
117    pub compose_file: String,
118    /// Explicit compose project name (`-p`). OPTIONAL — omit to honor
119    /// the rig's own `.env` `COMPOSE_PROJECT_NAME` (the identity truth,
120    /// research Pattern 1); set only to override it deliberately.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub project_name: Option<String>,
123}
124
125/// One gateway profile.
126#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127pub struct Profile {
128    /// Gateway base URL.
129    pub url: url::Url,
130    /// Optional display label (CORE-01) — absent from TOML and JSON when
131    /// unset so existing configs and goldens don't churn.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub label: Option<String>,
134    /// Verify TLS certificates (self-signed dev rigs turn this off).
135    #[serde(default = "default_ssl_verify")]
136    pub ssl_verify: bool,
137    /// HOW to find the credential — a reference, never a value.
138    #[serde(default)]
139    pub auth: AuthRef,
140    /// The CLI-GENERATED webdev scriptExec shared secret (05-03) — the
141    /// ONE deliberate value-carrying exception to the references-only
142    /// rule: this secret is not a user credential but a token the CLI
143    /// itself mints at deploy time, must round-trip verbatim (the
144    /// deployed route compares it byte-for-byte), and cannot live in
145    /// an env var another tool owns. It rides ONLY in this 0600 config
146    /// store and the baked route zip member — never in any action
147    /// result, log, or JSON envelope (the redaction discipline).
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub webdev_secret: Option<String>,
150    /// TUI background-refresh poll interval in seconds (TUIX-05
151    /// plumbing). `None` = the default cadence. Sub-second values
152    /// (`Some(0)`) are REFUSED by load-time validation on the normal CLI
153    /// path (exit 3, `poll_interval_too_small`) and degraded to `None`
154    /// on the TUI path ([`super::load_for_tui`]); a wrong-TYPED value
155    /// degrades to `None` with a warning (see [`lenient_u64`]) — a typo
156    /// in the new key never bricks the load.
157    #[serde(
158        default,
159        skip_serializing_if = "Option::is_none",
160        deserialize_with = "lenient_u64"
161    )]
162    pub poll_interval_secs: Option<u64>,
163}
164
165fn default_ssl_verify() -> bool {
166    true
167}
168
169/// Credential reference — the three supported forms, untagged so the TOML
170/// stays flat: `auth = { token_env = "X" }`, `auth = { keyring =
171/// "profile:prod" }`, or `auth = { user_env = "U", password_env = "P" }`.
172#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
173#[serde(untagged)]
174pub enum AuthRef {
175    /// Token lives in this env var.
176    TokenEnv {
177        /// Env var NAME (never the token itself).
178        token_env: String,
179    },
180    /// Token lives in the OS keyring (service `ignition-cli`) under this
181    /// user string, e.g. `"profile:prod"`.
182    Keyring {
183        /// Keyring user string.
184        keyring: String,
185    },
186    /// Basic pair from two env vars.
187    Basic {
188        /// Env var NAME for the user.
189        user_env: String,
190        /// Env var NAME for the password.
191        password_env: String,
192    },
193}
194
195impl AuthRef {
196    /// Safe kind string for output models — never a secret or env value.
197    pub fn kind(&self) -> &'static str {
198        match self {
199            Self::TokenEnv { .. } => "token_env",
200            Self::Keyring { .. } => "keyring",
201            Self::Basic { .. } => "basic",
202        }
203    }
204}
205
206impl Default for AuthRef {
207    /// A profile without an `auth` key resolves through the generic env
208    /// token path (`IGNITION_TOKEN`) — the last env-token step of the LOCKED
209    /// resolution order.
210    fn default() -> Self {
211        Self::TokenEnv {
212            token_env: "IGNITION_TOKEN".to_string(),
213        }
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::{AuthRef, Config};
220
221    /// AuthRef untagged round-trip across all three reference forms, and
222    /// `kind()` never leaks a value.
223    #[test]
224    fn auth_ref_untagged_round_trip() {
225        let toml = r#"
226active = "dev"
227
228[profiles.dev]
229url = "http://localhost:9088/"
230label = "Dev rig"
231auth = { token_env = "IGNITION_TOKEN" }
232
233[profiles.prod]
234url = "https://gw.example.com:8443/"
235auth = { keyring = "profile:prod" }
236
237[profiles.rig]
238url = "http://10.0.0.5:9088/"
239ssl_verify = false
240auth = { user_env = "IGNITION_USER", password_env = "IGNITION_PASSWORD" }
241"#;
242        let config: Config = toml::from_str(toml).expect("parse");
243        assert_eq!(config.active.as_deref(), Some("dev"));
244        assert_eq!(config.profiles.len(), 3);
245
246        let dev = &config.profiles["dev"];
247        assert_eq!(dev.label.as_deref(), Some("Dev rig"));
248        assert!(dev.ssl_verify, "ssl_verify defaults true");
249        assert_eq!(
250            dev.auth,
251            AuthRef::TokenEnv {
252                token_env: "IGNITION_TOKEN".into()
253            }
254        );
255        assert_eq!(dev.auth.kind(), "token_env");
256
257        let prod = &config.profiles["prod"];
258        assert_eq!(prod.label, None, "label absent when unset");
259        assert_eq!(
260            prod.auth,
261            AuthRef::Keyring {
262                keyring: "profile:prod".into()
263            }
264        );
265        assert_eq!(prod.auth.kind(), "keyring");
266
267        let rig = &config.profiles["rig"];
268        assert!(!rig.ssl_verify, "ssl_verify = false honored");
269        assert_eq!(
270            rig.auth,
271            AuthRef::Basic {
272                user_env: "IGNITION_USER".into(),
273                password_env: "IGNITION_PASSWORD".into(),
274            }
275        );
276        assert_eq!(rig.auth.kind(), "basic");
277
278        // Serialize round-trip: label omitted when None, ssl_verify omitted
279        // when default (serde defaults skip nothing here except via attrs).
280        let reserialized = toml::to_string(&config).expect("serialize");
281        assert!(reserialized.contains("label = \"Dev rig\""));
282        assert!(!reserialized.contains("[profiles.prod]\nlabel"));
283        let back: Config = toml::from_str(&reserialized).expect("re-parse");
284        assert_eq!(back, config);
285    }
286
287    /// Missing `auth` falls back to the generic env token reference.
288    #[test]
289    fn auth_defaults_to_generic_token_env() {
290        let toml = "[profiles.dev]\nurl = \"http://localhost:9088/\"\n";
291        let config: Config = toml::from_str(toml).expect("parse");
292        assert_eq!(
293            config.profiles["dev"].auth,
294            AuthRef::TokenEnv {
295                token_env: "IGNITION_TOKEN".into()
296            }
297        );
298    }
299
300    /// New-schema round trip (TUIX-05): `[ui]` + `poll_interval_secs`
301    /// survive load → serialize → load losslessly.
302    #[test]
303    fn ui_and_poll_interval_round_trip() {
304        let toml = r#"
305active = "dev"
306
307[ui]
308theme = "dark"
309
310[profiles.dev]
311url = "http://localhost:9088/"
312poll_interval_secs = 10
313"#;
314        let config: Config = toml::from_str(toml).expect("parse");
315        assert_eq!(config.ui.theme.as_deref(), Some("dark"));
316        assert_eq!(config.profiles["dev"].poll_interval_secs, Some(10));
317
318        let reserialized = toml::to_string(&config).expect("serialize");
319        let back: Config = toml::from_str(&reserialized).expect("re-parse");
320        assert_eq!(back, config, "round trip must be lossless");
321    }
322
323    /// Legacy-shape pin: a config without the new keys serializes WITHOUT
324    /// them — existing configs keep their exact on-disk shape (goldens).
325    #[test]
326    fn legacy_config_serializes_without_new_keys() {
327        let toml = "[profiles.dev]\nurl = \"http://localhost:9088/\"\n";
328        let config: Config = toml::from_str(toml).expect("parse");
329        let out = toml::to_string(&config).expect("serialize");
330        assert!(
331            !out.contains("ui") && !out.contains("poll_interval_secs"),
332            "new keys must stay off legacy-shaped configs: {out}"
333        );
334    }
335
336    /// Lenient degrade (TUIX-05): a wrong-TYPED value in the NEW surface
337    /// never fails the load — it warns and falls back to defaults.
338    #[test]
339    fn lenient_degrade_on_bad_typed_new_keys() {
340        let toml = r#"
341[ui]
342theme = "dark"
343
344[profiles.dev]
345url = "http://localhost:9088/"
346poll_interval_secs = "banana"
347"#;
348        let config: Config = toml::from_str(toml).expect("typo'd new key must not fail the load");
349        assert_eq!(
350            config.profiles["dev"].poll_interval_secs, None,
351            "bad poll_interval_secs degrades to None"
352        );
353
354        let toml = "ui = 42\n[profiles.dev]\nurl = \"http://localhost:9088/\"\n";
355        let config: Config = toml::from_str(toml).expect("bad [ui] shape must not fail the load");
356        assert_eq!(
357            config.ui,
358            super::UiConfig::default(),
359            "non-table [ui] degrades to the default"
360        );
361    }
362
363    /// A well-shaped `[ui]` table with FUTURE keys inside still loads —
364    /// unknown keys inside are ignored, never denied (Pitfall 7).
365    #[test]
366    fn ui_table_with_unknown_keys_still_loads() {
367        let toml = r#"
368[ui]
369theme = "dark"
370future_key = "whatever"
371
372[profiles.dev]
373url = "http://localhost:9088/"
374"#;
375        let config: Config = toml::from_str(toml).expect("parse");
376        assert_eq!(config.ui.theme.as_deref(), Some("dark"));
377    }
378
379    /// The clamp's floor belongs to validation, not serde: 0 parses fine
380    /// here (permissive deserializer) and is refused by `load` (tested in
381    /// `config::tests`).
382    #[test]
383    fn poll_interval_zero_parses_leniently() {
384        let toml = "[profiles.dev]\nurl = \"http://localhost:9088/\"\npoll_interval_secs = 0\n";
385        let config: Config = toml::from_str(toml).expect("parse");
386        assert_eq!(config.profiles["dev"].poll_interval_secs, Some(0));
387    }
388}