1use std::collections::BTreeMap;
10
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
16pub struct Config {
17 #[serde(default, skip_serializing_if = "Option::is_none")]
19 pub active: Option<String>,
20 #[serde(default)]
22 pub profiles: BTreeMap<String, Profile>,
23 #[serde(default, skip_serializing_if = "rig_config_is_empty")]
26 pub rig: RigConfig,
27 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
31 pub rigs: BTreeMap<String, RigEntry>,
32 #[serde(
38 default,
39 skip_serializing_if = "UiConfig::is_default",
40 deserialize_with = "lenient_ui"
41 )]
42 pub ui: UiConfig,
43}
44
45#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
47pub struct UiConfig {
48 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub theme: Option<String>,
52}
53
54impl UiConfig {
55 pub fn is_default(ui: &UiConfig) -> bool {
58 *ui == UiConfig::default()
59 }
60}
61
62fn 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
81fn 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
96fn rig_config_is_empty(rig: &RigConfig) -> bool {
99 rig.default.is_none()
100}
101
102#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
104pub struct RigConfig {
105 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub default: Option<String>,
109}
110
111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
114pub struct RigEntry {
115 pub compose_file: String,
118 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub project_name: Option<String>,
123}
124
125#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
127pub struct Profile {
128 pub url: url::Url,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub label: Option<String>,
134 #[serde(default = "default_ssl_verify")]
136 pub ssl_verify: bool,
137 #[serde(default)]
139 pub auth: AuthRef,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub webdev_secret: Option<String>,
150 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
173#[serde(untagged)]
174pub enum AuthRef {
175 TokenEnv {
177 token_env: String,
179 },
180 Keyring {
183 keyring: String,
185 },
186 Basic {
188 user_env: String,
190 password_env: String,
192 },
193}
194
195impl AuthRef {
196 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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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}