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    /// Declared third-party modules to inject via the generated compose
124    /// override (`[rigs.NAME.modules.*]`, Phase 16, D-01): a MAP, so a
125    /// rig can declare any number of modules, each pinned to its own
126    /// version. Empty by construction on every discovery path except
127    /// [`super::super::rig::resolve_entry`] — the structural reason
128    /// SC-1 (an undeclared rig behaves byte-identically to today) holds.
129    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
130    pub modules: BTreeMap<String, ModuleDeclaration>,
131    /// Escape hatch (D-12) overriding the derived gateway service when
132    /// modules are declared: the DERIVED value (the first service
133    /// publishing a port targeting 8088/443) is used when this is
134    /// unset.
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub module_service: Option<String>,
137}
138
139/// One declared module under `[rigs.NAME.modules.ID]` (Phase 16, D-01):
140/// today just the pinned version, but its own table (not a bare string)
141/// so a future key never forces a shape migration.
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
143pub struct ModuleDeclaration {
144    /// The pinned version string (validated by
145    /// [`crate::module::validate_version`] before use — this struct
146    /// carries it verbatim, unvalidated, exactly like `compose_file`).
147    pub version: String,
148}
149
150/// One gateway profile.
151#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
152pub struct Profile {
153    /// Gateway base URL.
154    pub url: url::Url,
155    /// Optional display label (CORE-01) — absent from TOML and JSON when
156    /// unset so existing configs and goldens don't churn.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub label: Option<String>,
159    /// Verify TLS certificates (self-signed dev rigs turn this off).
160    #[serde(default = "default_ssl_verify")]
161    pub ssl_verify: bool,
162    /// HOW to find the credential — a reference, never a value.
163    #[serde(default)]
164    pub auth: AuthRef,
165    /// The CLI-GENERATED webdev scriptExec shared secret (05-03) — the
166    /// ONE deliberate value-carrying exception to the references-only
167    /// rule: this secret is not a user credential but a token the CLI
168    /// itself mints at deploy time, must round-trip verbatim (the
169    /// deployed route compares it byte-for-byte), and cannot live in
170    /// an env var another tool owns. It rides ONLY in this 0600 config
171    /// store and the baked route zip member — never in any action
172    /// result, log, or JSON envelope (the redaction discipline).
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub webdev_secret: Option<String>,
175    /// TUI background-refresh poll interval in seconds (TUIX-05
176    /// plumbing). `None` = the default cadence. Sub-second values
177    /// (`Some(0)`) are REFUSED by load-time validation on the normal CLI
178    /// path (exit 3, `poll_interval_too_small`) and degraded to `None`
179    /// on the TUI path ([`super::load_for_tui`]); a wrong-TYPED value
180    /// degrades to `None` with a warning (see [`lenient_u64`]) — a typo
181    /// in the new key never bricks the load.
182    #[serde(
183        default,
184        skip_serializing_if = "Option::is_none",
185        deserialize_with = "lenient_u64"
186    )]
187    pub poll_interval_secs: Option<u64>,
188}
189
190fn default_ssl_verify() -> bool {
191    true
192}
193
194/// Credential reference — the three supported forms, untagged so the TOML
195/// stays flat: `auth = { token_env = "X" }`, `auth = { keyring =
196/// "profile:prod" }`, or `auth = { user_env = "U", password_env = "P" }`.
197#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
198#[serde(untagged)]
199pub enum AuthRef {
200    /// Token lives in this env var.
201    TokenEnv {
202        /// Env var NAME (never the token itself).
203        token_env: String,
204    },
205    /// Token lives in the OS keyring (service `ignition-cli`) under this
206    /// user string, e.g. `"profile:prod"`.
207    Keyring {
208        /// Keyring user string.
209        keyring: String,
210    },
211    /// Basic pair from two env vars.
212    Basic {
213        /// Env var NAME for the user.
214        user_env: String,
215        /// Env var NAME for the password.
216        password_env: String,
217    },
218}
219
220impl AuthRef {
221    /// Safe kind string for output models — never a secret or env value.
222    pub fn kind(&self) -> &'static str {
223        match self {
224            Self::TokenEnv { .. } => "token_env",
225            Self::Keyring { .. } => "keyring",
226            Self::Basic { .. } => "basic",
227        }
228    }
229}
230
231impl Default for AuthRef {
232    /// A profile without an `auth` key resolves through the generic env
233    /// token path (`IGNITION_TOKEN`) — the last env-token step of the LOCKED
234    /// resolution order.
235    fn default() -> Self {
236        Self::TokenEnv {
237            token_env: "IGNITION_TOKEN".to_string(),
238        }
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::{AuthRef, Config};
245
246    /// AuthRef untagged round-trip across all three reference forms, and
247    /// `kind()` never leaks a value.
248    #[test]
249    fn auth_ref_untagged_round_trip() {
250        let toml = r#"
251active = "dev"
252
253[profiles.dev]
254url = "http://localhost:9088/"
255label = "Dev rig"
256auth = { token_env = "IGNITION_TOKEN" }
257
258[profiles.prod]
259url = "https://gw.example.com:8443/"
260auth = { keyring = "profile:prod" }
261
262[profiles.rig]
263url = "http://10.0.0.5:9088/"
264ssl_verify = false
265auth = { user_env = "IGNITION_USER", password_env = "IGNITION_PASSWORD" }
266"#;
267        let config: Config = toml::from_str(toml).expect("parse");
268        assert_eq!(config.active.as_deref(), Some("dev"));
269        assert_eq!(config.profiles.len(), 3);
270
271        let dev = &config.profiles["dev"];
272        assert_eq!(dev.label.as_deref(), Some("Dev rig"));
273        assert!(dev.ssl_verify, "ssl_verify defaults true");
274        assert_eq!(
275            dev.auth,
276            AuthRef::TokenEnv {
277                token_env: "IGNITION_TOKEN".into()
278            }
279        );
280        assert_eq!(dev.auth.kind(), "token_env");
281
282        let prod = &config.profiles["prod"];
283        assert_eq!(prod.label, None, "label absent when unset");
284        assert_eq!(
285            prod.auth,
286            AuthRef::Keyring {
287                keyring: "profile:prod".into()
288            }
289        );
290        assert_eq!(prod.auth.kind(), "keyring");
291
292        let rig = &config.profiles["rig"];
293        assert!(!rig.ssl_verify, "ssl_verify = false honored");
294        assert_eq!(
295            rig.auth,
296            AuthRef::Basic {
297                user_env: "IGNITION_USER".into(),
298                password_env: "IGNITION_PASSWORD".into(),
299            }
300        );
301        assert_eq!(rig.auth.kind(), "basic");
302
303        // Serialize round-trip: label omitted when None, ssl_verify omitted
304        // when default (serde defaults skip nothing here except via attrs).
305        let reserialized = toml::to_string(&config).expect("serialize");
306        assert!(reserialized.contains("label = \"Dev rig\""));
307        assert!(!reserialized.contains("[profiles.prod]\nlabel"));
308        let back: Config = toml::from_str(&reserialized).expect("re-parse");
309        assert_eq!(back, config);
310    }
311
312    /// Missing `auth` falls back to the generic env token reference.
313    #[test]
314    fn auth_defaults_to_generic_token_env() {
315        let toml = "[profiles.dev]\nurl = \"http://localhost:9088/\"\n";
316        let config: Config = toml::from_str(toml).expect("parse");
317        assert_eq!(
318            config.profiles["dev"].auth,
319            AuthRef::TokenEnv {
320                token_env: "IGNITION_TOKEN".into()
321            }
322        );
323    }
324
325    /// New-schema round trip (TUIX-05): `[ui]` + `poll_interval_secs`
326    /// survive load → serialize → load losslessly.
327    #[test]
328    fn ui_and_poll_interval_round_trip() {
329        let toml = r#"
330active = "dev"
331
332[ui]
333theme = "dark"
334
335[profiles.dev]
336url = "http://localhost:9088/"
337poll_interval_secs = 10
338"#;
339        let config: Config = toml::from_str(toml).expect("parse");
340        assert_eq!(config.ui.theme.as_deref(), Some("dark"));
341        assert_eq!(config.profiles["dev"].poll_interval_secs, Some(10));
342
343        let reserialized = toml::to_string(&config).expect("serialize");
344        let back: Config = toml::from_str(&reserialized).expect("re-parse");
345        assert_eq!(back, config, "round trip must be lossless");
346    }
347
348    /// Legacy-shape pin: a config without the new keys serializes WITHOUT
349    /// them — existing configs keep their exact on-disk shape (goldens).
350    #[test]
351    fn legacy_config_serializes_without_new_keys() {
352        let toml = "[profiles.dev]\nurl = \"http://localhost:9088/\"\n";
353        let config: Config = toml::from_str(toml).expect("parse");
354        let out = toml::to_string(&config).expect("serialize");
355        assert!(
356            !out.contains("ui") && !out.contains("poll_interval_secs"),
357            "new keys must stay off legacy-shaped configs: {out}"
358        );
359    }
360
361    /// Lenient degrade (TUIX-05): a wrong-TYPED value in the NEW surface
362    /// never fails the load — it warns and falls back to defaults.
363    #[test]
364    fn lenient_degrade_on_bad_typed_new_keys() {
365        let toml = r#"
366[ui]
367theme = "dark"
368
369[profiles.dev]
370url = "http://localhost:9088/"
371poll_interval_secs = "banana"
372"#;
373        let config: Config = toml::from_str(toml).expect("typo'd new key must not fail the load");
374        assert_eq!(
375            config.profiles["dev"].poll_interval_secs, None,
376            "bad poll_interval_secs degrades to None"
377        );
378
379        let toml = "ui = 42\n[profiles.dev]\nurl = \"http://localhost:9088/\"\n";
380        let config: Config = toml::from_str(toml).expect("bad [ui] shape must not fail the load");
381        assert_eq!(
382            config.ui,
383            super::UiConfig::default(),
384            "non-table [ui] degrades to the default"
385        );
386    }
387
388    /// A well-shaped `[ui]` table with FUTURE keys inside still loads —
389    /// unknown keys inside are ignored, never denied (Pitfall 7).
390    #[test]
391    fn ui_table_with_unknown_keys_still_loads() {
392        let toml = r#"
393[ui]
394theme = "dark"
395future_key = "whatever"
396
397[profiles.dev]
398url = "http://localhost:9088/"
399"#;
400        let config: Config = toml::from_str(toml).expect("parse");
401        assert_eq!(config.ui.theme.as_deref(), Some("dark"));
402    }
403
404    /// SC-1's config-layer half (D-01/D-12): a `[rigs.NAME]` table with
405    /// no `[rigs.NAME.modules.*]` sub-table round-trips byte-identically
406    /// — `modules`/`module_service` never appear in the re-serialized
407    /// TOML when unset.
408    #[test]
409    fn rig_entry_without_modules_round_trips_byte_identically() {
410        let toml = "[rigs.dev]\ncompose_file = \"docker/compose.yml\"\n";
411        let config: Config = toml::from_str(toml).expect("parse");
412        assert!(config.rigs["dev"].modules.is_empty());
413        assert_eq!(config.rigs["dev"].module_service, None);
414        // Compared against the FULL serialized config, not the input
415        // fragment: `toml::to_string` of a `Config` always emits the
416        // `[profiles]` table, so a fragment compare fails regardless of
417        // anything this phase changed. The exact-output form is the
418        // stronger assertion anyway — it fails the moment `modules`,
419        // `module_service`, or any other new key appears.
420        let reserialized = toml::to_string(&config).expect("serialize");
421        assert_eq!(
422            reserialized, "[profiles]\n\n[rigs.dev]\ncompose_file = \"docker/compose.yml\"\n",
423            "a rig with no modules table must serialize byte-identically"
424        );
425    }
426
427    /// The documented `[rigs.NAME.modules.ID]` shape parses (D-01): a
428    /// `[rigs.dev]` table carrying `compose_file`, followed by a
429    /// `[rigs.dev.modules.git]` table carrying `version`.
430    #[test]
431    fn rig_entry_modules_table_parses() {
432        let toml = r#"
433[rigs.dev]
434compose_file = "docker/compose.yml"
435
436[rigs.dev.modules.git]
437version = "2.3.4"
438"#;
439        let config: Config = toml::from_str(toml).expect("parse");
440        let dev = &config.rigs["dev"];
441        assert_eq!(dev.modules.len(), 1);
442        assert_eq!(dev.modules["git"].version, "2.3.4");
443        assert_eq!(dev.module_service, None);
444
445        let reserialized = toml::to_string(&config).expect("serialize");
446        let back: Config = toml::from_str(&reserialized).expect("re-parse");
447        assert_eq!(back, config, "round trip must be lossless");
448    }
449
450    /// The clamp's floor belongs to validation, not serde: 0 parses fine
451    /// here (permissive deserializer) and is refused by `load` (tested in
452    /// `config::tests`).
453    #[test]
454    fn poll_interval_zero_parses_leniently() {
455        let toml = "[profiles.dev]\nurl = \"http://localhost:9088/\"\npoll_interval_secs = 0\n";
456        let config: Config = toml::from_str(toml).expect("parse");
457        assert_eq!(config.profiles["dev"].poll_interval_secs, Some(0));
458    }
459}