1pub mod profile;
16pub mod secret;
17
18pub use profile::{AuthRef, Config, Profile, RigConfig, RigEntry, UiConfig};
19pub use secret::{
20 BasicEnvStore, Credential, EnvStore, KeyringStore, Secret, SecretStore, resolve_secret,
21};
22
23use std::path::{Path, PathBuf};
24
25use crate::error::CoreError;
26
27#[cfg(test)]
31pub(crate) static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
32
33pub fn config_path() -> PathBuf {
36 std::env::var_os("IGNITION_CLI_CONFIG")
37 .map(PathBuf::from)
38 .unwrap_or_else(|| {
39 let dirs = directories::ProjectDirs::from("", "", "ignition-cli")
40 .expect("no home directory discoverable");
41 dirs.config_dir().join("config.toml")
42 })
43}
44
45pub fn load(path: &Path) -> Result<Config, CoreError> {
52 load_inner(path, true)
53}
54
55pub fn load_for_tui(path: &Path) -> Result<Config, CoreError> {
68 load_inner(path, false)
69}
70
71fn load_inner(path: &Path, strict_clamp: bool) -> Result<Config, CoreError> {
74 let raw = match std::fs::read_to_string(path) {
75 Ok(raw) => raw,
76 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
77 return Ok(Config::default());
78 }
79 Err(err) => {
80 return Err(CoreError::ConfigInvalid {
81 reason: format!("cannot read {}: {err}", path.display()),
82 });
83 }
84 };
85 if raw.trim().is_empty() {
86 return Ok(Config::default());
87 }
88 warn_unknown_keys(&raw);
89 let mut config: Config = toml::from_str(&raw).map_err(|err| CoreError::ConfigInvalid {
90 reason: format!("{}: {err}", path.display()),
91 })?;
92 if strict_clamp {
93 validate(&config)?;
94 } else {
95 degrade_clamp_violations(&mut config);
96 }
97 Ok(config)
98}
99
100fn validate(config: &Config) -> Result<(), CoreError> {
106 for (name, profile) in &config.profiles {
107 if profile.poll_interval_secs == Some(0) {
108 return Err(CoreError::PollIntervalTooSmall {
109 profile: name.clone(),
110 });
111 }
112 }
113 Ok(())
114}
115
116fn degrade_clamp_violations(config: &mut Config) {
121 for (name, profile) in &mut config.profiles {
122 if profile.poll_interval_secs == Some(0) {
123 profile.poll_interval_secs = None;
124 tracing::warn!(
125 slug = "poll_interval_too_small",
126 profile = %name,
127 "poll_interval_secs must be >= 1 (sub-second polling refused) — using the default cadence"
128 );
129 }
130 }
131}
132
133const KNOWN_TOP_LEVEL: &[&str] = &["active", "profiles", "rig", "rigs", "ui"];
134const KNOWN_PROFILE_KEYS: &[&str] = &[
135 "url",
136 "label",
137 "ssl_verify",
138 "auth",
139 "webdev_secret",
140 "poll_interval_secs",
141];
142const KNOWN_AUTH_KEYS: &[&str] = &["token_env", "keyring", "user_env", "password_env"];
143
144fn warn_unknown_keys(raw: &str) {
147 let Ok(table) = raw.parse::<toml::Table>() else {
148 return;
149 };
150 for (key, value) in &table {
151 if !KNOWN_TOP_LEVEL.contains(&key.as_str()) {
152 tracing::warn!(key = %key, "unknown config key (ignored)");
153 }
154 if key != "profiles" {
155 continue;
156 }
157 let Some(profiles) = value.as_table() else {
158 continue;
159 };
160 for (name, profile_value) in profiles {
161 let Some(profile_table) = profile_value.as_table() else {
162 continue;
163 };
164 for (profile_key, auth_value) in profile_table {
165 if !KNOWN_PROFILE_KEYS.contains(&profile_key.as_str()) {
166 tracing::warn!(profile = %name, key = %profile_key, "unknown profile key (ignored)");
167 }
168 if profile_key == "auth"
169 && let Some(auth_table) = auth_value.as_table()
170 {
171 for auth_key in auth_table.keys() {
172 if !KNOWN_AUTH_KEYS.contains(&auth_key.as_str()) {
173 tracing::warn!(profile = %name, key = auth_key, "unknown auth key (ignored)");
174 }
175 }
176 }
177 }
178 }
179 }
180}
181
182pub fn save(path: &Path, config: &Config) -> Result<(), CoreError> {
186 if let Some(parent) = path.parent() {
187 std::fs::create_dir_all(parent).map_err(|err| CoreError::ConfigInvalid {
188 reason: format!("cannot create {}: {err}", parent.display()),
189 })?;
190 }
191 let contents = toml::to_string_pretty(config).map_err(|err| CoreError::ConfigInvalid {
192 reason: format!("cannot serialize config: {err}"),
193 })?;
194 std::fs::write(path, contents).map_err(|err| CoreError::ConfigInvalid {
195 reason: format!("cannot write {}: {err}", path.display()),
196 })?;
197 enforce_0600(path)
198}
199
200#[cfg(unix)]
203fn enforce_0600(path: &Path) -> Result<(), CoreError> {
204 use std::os::unix::fs::PermissionsExt;
205
206 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|err| {
207 CoreError::ConfigInvalid {
208 reason: format!("cannot set 0600 on {}: {err}", path.display()),
209 }
210 })
211}
212
213#[cfg(not(unix))]
214fn enforce_0600(_path: &Path) -> Result<(), CoreError> {
215 Ok(())
216}
217
218pub fn apply_env_overlay(config: &mut Config, selected_profile: Option<&str>) {
223 let Some(name) = selected_profile else { return };
224 let Ok(url_string) = std::env::var("IGNITION_URL") else {
225 return;
226 };
227 if url_string.is_empty() {
228 return;
229 }
230 let Ok(url) = url::Url::parse(&url_string) else {
231 tracing::warn!(url = %url_string, "IGNITION_URL is not a valid URL; ignoring");
232 return;
233 };
234 if let Some(profile) = config.profiles.get_mut(name) {
235 profile.url = url;
236 }
237}
238
239pub fn resolve_selection(
247 config: &Config,
248 flag: Option<&str>,
249) -> Result<Option<(String, Profile)>, CoreError> {
250 let name = match flag.map(str::to_owned).or_else(|| config.active.clone()) {
251 Some(name) => name,
252 None => return Ok(None),
253 };
254 match config.profiles.get(&name) {
255 Some(profile) => Ok(Some((name, profile.clone()))),
256 None => Err(CoreError::ProfileNotFound {
257 name,
258 known: config.profiles.keys().cloned().collect(),
259 }),
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use super::{
266 Config, Profile, apply_env_overlay, config_path, load, load_for_tui, resolve_selection,
267 save,
268 };
269 use crate::config::AuthRef;
270 use crate::config::ENV_LOCK;
271 use crate::error::CoreError;
272
273 use std::path::PathBuf;
274
275 fn temp_config_path() -> (tempfile::TempDir, PathBuf) {
276 let dir = tempfile::tempdir().expect("tempdir");
277 let path = dir.path().join("config.toml");
278 (dir, path)
279 }
280
281 fn sample_config() -> Config {
282 let mut config = Config {
283 active: Some("dev".into()),
284 ..Config::default()
285 };
286 config.profiles.insert(
287 "dev".into(),
288 Profile {
289 url: "http://localhost:9088/".parse().expect("url"),
290 label: Some("Dev rig".into()),
291 ssl_verify: true,
292 auth: AuthRef::TokenEnv {
293 token_env: "IGNITION_TOKEN".into(),
294 },
295 webdev_secret: None,
296 poll_interval_secs: None,
297 },
298 );
299 config.profiles.insert(
300 "prod".into(),
301 Profile {
302 url: "https://gw.example.com:8443/".parse().expect("url"),
303 label: None,
304 ssl_verify: true,
305 auth: AuthRef::Keyring {
306 keyring: "profile:prod".into(),
307 },
308 webdev_secret: None,
309 poll_interval_secs: None,
310 },
311 );
312 config
313 }
314
315 #[test]
318 fn round_trip_save_load() {
319 let (_dir, path) = temp_config_path();
320 let config = sample_config();
321
322 save(&path, &config).expect("save");
323 let reloaded = load(&path).expect("load");
324 assert_eq!(reloaded, config, "round trip must be lossless");
325
326 let raw = std::fs::read_to_string(&path).expect("read raw");
327 assert!(raw.contains("label = \"Dev rig\""));
328 let prod_section = raw
329 .split("[profiles.prod]")
330 .nth(1)
331 .expect("prod section serialized");
332 assert!(
333 !prod_section.contains("label"),
334 "unset label must not be serialized: {prod_section}",
335 );
336 }
337
338 #[test]
341 #[cfg(unix)]
342 fn save_enforces_0600_and_creates_parents() {
343 use std::os::unix::fs::PermissionsExt;
344
345 let (_dir, path) = temp_config_path();
346 let nested = path.parent().unwrap().join("nested/deeper/config.toml");
347
348 save(&nested, &sample_config()).expect("save creates parent dirs");
349 let mode = std::fs::metadata(&nested)
350 .expect("metadata")
351 .permissions()
352 .mode();
353 assert_eq!(mode & 0o777, 0o600, "fresh config must be 0600");
354
355 std::fs::set_permissions(&nested, std::fs::Permissions::from_mode(0o644)).expect("loosen");
357 save(&nested, &sample_config()).expect("save again");
358 let mode = std::fs::metadata(&nested)
359 .expect("metadata")
360 .permissions()
361 .mode();
362 assert_eq!(mode & 0o777, 0o600, "overwrite must re-assert 0600");
363 }
364
365 #[test]
367 fn unknown_keys_warn_but_do_not_fail() {
368 let (_dir, path) = temp_config_path();
369 std::fs::write(
370 &path,
371 r#"
372future_top_level = "whatever"
373active = "dev"
374
375[profiles.dev]
376url = "http://localhost:9088/"
377future_profile_key = 42
378
379[profiles.dev.auth]
380token_env = "IGNITION_TOKEN"
381future_auth_key = "x"
382"#,
383 )
384 .expect("write");
385
386 let config = load(&path).expect("unknown keys must not fail the load");
387 assert_eq!(config.active.as_deref(), Some("dev"));
388 assert!(config.profiles.contains_key("dev"));
389 }
390
391 #[test]
396 fn new_schema_keys_are_warn_silent() {
397 assert!(
398 super::KNOWN_TOP_LEVEL.contains(&"ui"),
399 "KNOWN_TOP_LEVEL must carry \"ui\""
400 );
401 assert!(
402 super::KNOWN_PROFILE_KEYS.contains(&"poll_interval_secs"),
403 "KNOWN_PROFILE_KEYS must carry \"poll_interval_secs\""
404 );
405
406 let (_dir, path) = temp_config_path();
408 std::fs::write(
409 &path,
410 r#"
411[ui]
412theme = "dark"
413
414[profiles.dev]
415url = "http://localhost:9088/"
416poll_interval_secs = 10
417"#,
418 )
419 .expect("write");
420 let config = load(&path).expect("new keys must not fail the load");
421 assert_eq!(config.ui.theme.as_deref(), Some("dark"));
422 assert_eq!(config.profiles["dev"].poll_interval_secs, Some(10));
423 }
424
425 #[test]
429 fn poll_interval_zero_is_refused() {
430 let (_dir, path) = temp_config_path();
431 std::fs::write(
432 &path,
433 "[profiles.dev]\nurl = \"http://localhost:9088/\"\npoll_interval_secs = 0\n",
434 )
435 .expect("write");
436
437 let err = load(&path).expect_err("0 must be refused");
438 assert_eq!(err.code(), "poll_interval_too_small");
439 assert_eq!(err.exit_code(), 3, "config class — no new exit code");
440 let message = err.to_string();
441 assert!(
442 message.contains("dev") && message.contains("sub-second"),
443 "refusal must name the profile + the rule: {message}"
444 );
445 let hint = err.hint().expect("hint required");
446 assert!(
447 hint.contains("[profiles.dev]") && hint.contains("poll_interval_secs"),
448 "hint must point at the profile key: {hint}"
449 );
450 }
451
452 #[test]
454 fn poll_interval_one_is_the_floor() {
455 let (_dir, path) = temp_config_path();
456 std::fs::write(
457 &path,
458 "[profiles.dev]\nurl = \"http://localhost:9088/\"\npoll_interval_secs = 1\n",
459 )
460 .expect("write");
461
462 let config = load(&path).expect("1 is the floor — must load");
463 assert_eq!(config.profiles["dev"].poll_interval_secs, Some(1));
464 }
465
466 #[test]
469 fn load_for_tui_degrades_clamp_violation() {
470 let (_dir, path) = temp_config_path();
471 std::fs::write(
472 &path,
473 "[profiles.dev]\nurl = \"http://localhost:9088/\"\npoll_interval_secs = 0\n",
474 )
475 .expect("write");
476
477 let config = load_for_tui(&path).expect("the TUI degrades the clamp instead of refusing");
478 assert_eq!(
479 config.profiles["dev"].poll_interval_secs, None,
480 "sub-second value substituted with the default cadence"
481 );
482 }
483
484 #[test]
489 fn load_for_tui_still_refuses_broken_profile_url() {
490 let (_dir, path) = temp_config_path();
491 std::fs::write(
492 &path,
493 "[profiles.dev]\nurl = \"not a url at all\"\npoll_interval_secs = 5\n",
494 )
495 .expect("write");
496
497 let err = load_for_tui(&path).expect_err("broken profile url is fatal");
498 assert_eq!(err.exit_code(), 3, "config_invalid class");
499 assert_eq!(err.code(), "config_invalid");
500 }
501
502 #[test]
504 fn load_for_tui_still_refuses_garbage_toml() {
505 let (_dir, path) = temp_config_path();
506 std::fs::write(&path, "this is ][ not toml\n").expect("write");
507
508 let err = load_for_tui(&path).expect_err("garbage toml is fatal");
509 assert_eq!(err.exit_code(), 3);
510 assert_eq!(err.code(), "config_invalid");
511 }
512
513 #[test]
517 fn missing_file_and_no_selection_resolve_none() {
518 let (_dir, path) = temp_config_path();
519 assert!(!path.exists(), "fixture sanity");
520
521 let config = load(&path).expect("missing file is not an error");
522 assert_eq!(config, Config::default());
523
524 let selection =
525 resolve_selection(&config, None).expect("no active + no flag is not an error");
526 assert!(selection.is_none());
527 }
528
529 #[test]
532 fn unknown_profile_lists_known() {
533 let config = sample_config();
534 let err = resolve_selection(&config, Some("nope")).expect_err("unknown profile errors");
535 match err {
536 CoreError::ProfileNotFound {
537 ref name,
538 ref known,
539 } => {
540 assert_eq!(name, "nope");
541 assert_eq!(known, &vec!["dev".to_string(), "prod".to_string()]);
542 }
543 other => panic!("wrong error class: {other}"),
544 }
545 assert_eq!(err.exit_code(), 3);
546 let hint = err.hint().expect("hint");
547 assert!(
548 hint.contains("dev") && hint.contains("prod"),
549 "hint names knowns: {hint}"
550 );
551 }
552
553 #[test]
555 fn selection_flag_beats_active() {
556 let config = sample_config(); let (name, profile) = resolve_selection(&config, Some("prod"))
558 .expect("flag selects prod")
559 .expect("some");
560 assert_eq!(name, "prod");
561 assert_eq!(
562 profile.auth,
563 AuthRef::Keyring {
564 keyring: "profile:prod".into()
565 }
566 );
567 }
568
569 #[test]
572 fn env_overlay_overrides_selected_profile_url() {
573 let _lock = ENV_LOCK.lock().expect("env lock");
574 unsafe { std::env::set_var("IGNITION_URL", "http://override.example:7000") };
576
577 let mut config = sample_config();
578 apply_env_overlay(&mut config, Some("dev"));
579 assert_eq!(
580 config.profiles["dev"].url.as_str(),
581 "http://override.example:7000/",
582 "selected profile URL overridden",
583 );
584 assert_eq!(
585 config.profiles["prod"].url.as_str(),
586 "https://gw.example.com:8443/",
587 "other profiles untouched",
588 );
589
590 let mut config = sample_config();
592 apply_env_overlay(&mut config, None);
593 assert_eq!(
594 config.profiles["dev"].url.as_str(),
595 "http://localhost:9088/",
596 "no selected profile → overlay is a no-op",
597 );
598
599 unsafe { std::env::remove_var("IGNITION_URL") };
601 }
602
603 #[test]
606 fn config_path_env_override_first() {
607 let _lock = ENV_LOCK.lock().expect("env lock");
608 let dir = tempfile::tempdir().expect("tempdir");
609 let override_path = dir.path().join("my-config.toml");
610
611 unsafe { std::env::set_var("IGNITION_CLI_CONFIG", &override_path) };
613 assert_eq!(config_path(), override_path, "env override wins");
614 unsafe { std::env::remove_var("IGNITION_CLI_CONFIG") };
616
617 assert!(
618 config_path().ends_with("config.toml"),
619 "platform fallback lands on config.toml: {}",
620 config_path().display(),
621 );
622 }
623}