Skip to main content

cli_engine/
config.rs

1//! Engine configuration file and credential-storage selection.
2//!
3//! cli-engine reads an optional per-application TOML config file at
4//! `<config-base>/<app_id>/config.toml`, where `<config-base>` is
5//! `$XDG_CONFIG_HOME`, `$HOME/Library/Application Support` (macOS),
6//! `$HOME/.config` (other Unix), or `%APPDATA%` (Windows) (see
7//! [`config_base_dir`](crate::fs::config_base_dir)).
8//! Loading is best-effort: a missing file yields defaults, and a malformed file
9//! logs a warning and falls back to defaults rather than failing the command.
10//!
11//! Two engine-reserved settings live here today:
12//!
13//! - Where credentials are stored — see [`CredentialStore`]. The effective
14//!   mode is resolved with the precedence
15//!
16//!   ```text
17//!   --credential-store flag  >  ${PREFIX}_CREDENTIAL_STORE env  >  config file  >  default (Auto)
18//!   ```
19//!
20//!   See [`resolve_credential_store`] and the pure
21//!   [`resolve_credential_store_with`].
22//!
23//! - The default output format for a user who never sets
24//!   `--output`/`--json`/`--human`/`--toon` — see [`crate::config::OutputConfig`]. Resolved
25//!   with the precedence
26//!
27//!   ```text
28//!   --output/--json/--human/--toon flag  >  ${PREFIX}_OUTPUT env  >  config file  >  TTY-based default
29//!   ```
30//!
31//!   See [`crate::flags::default_output_format`] and the pure
32//!   [`crate::flags::resolve_default_output_format`].
33//!
34//! where `${PREFIX}` is the app id sanitized by
35//! [`app_id_env_prefix`](crate::flags::app_id_env_prefix).
36
37use std::cell::Cell;
38use std::path::{Path, PathBuf};
39use std::str::FromStr;
40
41use serde::de::DeserializeOwned;
42use serde::{Deserialize, Deserializer};
43use toml_edit::DocumentMut;
44
45use crate::error::CliCoreError;
46
47/// Where an auth provider stores credentials.
48///
49/// The variant selects a concrete storage backend
50/// (see [`crate::auth::storage`]). `Auto` is the default and tries the system
51/// keychain first, falling back to an unencrypted file when the keychain is
52/// unavailable (headless Linux, WSL).
53#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
54#[non_exhaustive]
55pub enum CredentialStore {
56    /// Try the system keychain; transparently fall back to an unencrypted file
57    /// when the keychain backend is unavailable. This is the default.
58    #[default]
59    Auto,
60    /// System keychain only. A keychain failure is a hard error and no file is
61    /// ever written.
62    Keyring,
63    /// File only: never contact the system keychain. Credentials are written as
64    /// unencrypted JSON under the config base directory.
65    File,
66}
67
68impl CredentialStore {
69    /// Returns the lowercase canonical name (`auto`, `keyring`, or `file`).
70    #[must_use]
71    pub fn as_str(self) -> &'static str {
72        match self {
73            CredentialStore::Auto => "auto",
74            CredentialStore::Keyring => "keyring",
75            CredentialStore::File => "file",
76        }
77    }
78}
79
80impl std::fmt::Display for CredentialStore {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        f.write_str(self.as_str())
83    }
84}
85
86/// Error returned when a string does not name a [`CredentialStore`] variant.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct ParseCredentialStoreError(String);
89
90impl std::fmt::Display for ParseCredentialStoreError {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        write!(
93            f,
94            "invalid credential store {:?} (expected one of: auto, keyring, file)",
95            self.0
96        )
97    }
98}
99
100impl std::error::Error for ParseCredentialStoreError {}
101
102impl FromStr for CredentialStore {
103    type Err = ParseCredentialStoreError;
104
105    fn from_str(s: &str) -> Result<Self, Self::Err> {
106        match s.trim().to_ascii_lowercase().as_str() {
107            "auto" => Ok(CredentialStore::Auto),
108            // `keychain` is accepted as an alias for the keychain-only mode.
109            "keyring" | "keychain" => Ok(CredentialStore::Keyring),
110            "file" => Ok(CredentialStore::File),
111            _ => Err(ParseCredentialStoreError(s.to_owned())),
112        }
113    }
114}
115
116impl<'de> Deserialize<'de> for CredentialStore {
117    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
118    where
119        D: Deserializer<'de>,
120    {
121        let raw = String::deserialize(deserializer)?;
122        raw.parse().map_err(serde::de::Error::custom)
123    }
124}
125
126/// Top-level engine configuration parsed from `config.toml`.
127///
128/// Unknown keys are ignored so older binaries tolerate config written for newer
129/// ones. New sections can be added as additional fields over time.
130#[derive(Clone, Debug, Default, Deserialize)]
131#[serde(default)]
132pub struct EngineConfig {
133    /// Credential-storage settings (`[credentials]` table).
134    pub credentials: CredentialsConfig,
135    /// Output-format settings (`[output]` table).
136    pub output: OutputConfig,
137}
138
139/// The `[credentials]` table of the engine config file.
140#[derive(Clone, Debug, Default, Deserialize)]
141#[serde(default)]
142pub struct CredentialsConfig {
143    /// Selected credential store, or `None` when the key is absent.
144    pub store: Option<CredentialStore>,
145}
146
147/// The `[output]` table of the engine config file.
148#[derive(Clone, Debug, Default, Deserialize)]
149#[serde(default)]
150pub struct OutputConfig {
151    /// Default output format (`"json"`, `"human"`, or `"toon"`), or `None`
152    /// when the key is absent. Kept as a plain string rather than
153    /// [`crate::output::OutputFormat`] so validation stays in one place —
154    /// [`crate::flags::resolve_default_output_format`] — with the same
155    /// silently-ignored-if-invalid treatment already applied to the
156    /// `${PREFIX}_OUTPUT` env override (falls through to the next tier, no
157    /// log line), rather than `OutputFormat`'s permissive `FromStr` (which
158    /// silently maps unknown strings to `Json`).
159    pub format: Option<String>,
160}
161
162// Per-thread override from the `--credential-store` global flag.
163//
164// Stored in a `thread_local!` `Cell` so concurrent `Cli::run` calls on
165// different OS threads cannot interfere with each other. Each thread writes
166// its own flag at the start of `Cli::run` (via `set_credential_store_flag`)
167// and clears it at the end (via `clear_credential_store_flag`).
168//
169// Limitation: concurrent `Cli::run` calls sharing the same OS thread (e.g.
170// concurrent tokio tasks on a single-threaded runtime) are not supported —
171// the second call will observe the first run's flag. This scenario is atypical
172// for a CLI library.
173thread_local! {
174    static CREDENTIAL_STORE_FLAG: Cell<u8> = const { Cell::new(0) };
175}
176
177fn encode_store(store: Option<CredentialStore>) -> u8 {
178    match store {
179        None => 0,
180        Some(CredentialStore::Auto) => 1,
181        Some(CredentialStore::Keyring) => 2,
182        Some(CredentialStore::File) => 3,
183    }
184}
185
186fn decode_store(byte: u8) -> Option<CredentialStore> {
187    match byte {
188        1 => Some(CredentialStore::Auto),
189        2 => Some(CredentialStore::Keyring),
190        3 => Some(CredentialStore::File),
191        _ => None,
192    }
193}
194
195/// Records the value of the `--credential-store` flag for the current thread.
196///
197/// Called at the start of each CLI run with the parsed flag value (`None` when
198/// the flag was not supplied). Crate-internal: only the engine publishes
199/// per-run flag state, so library consumers cannot mutate this latch.
200pub(crate) fn set_credential_store_flag(store: Option<CredentialStore>) {
201    CREDENTIAL_STORE_FLAG.with(|f| f.set(encode_store(store)));
202}
203
204/// Clears the thread-local flag set by [`set_credential_store_flag`].
205///
206/// Called at the end of each `Cli::run` so the flag does not leak into
207/// subsequent sequential runs on the same thread.
208pub(crate) fn clear_credential_store_flag() {
209    CREDENTIAL_STORE_FLAG.with(|f| f.set(0));
210}
211
212/// Returns the flag override recorded by [`set_credential_store_flag`], if any.
213/// Crate-internal accessor for the per-thread flag latch.
214#[must_use]
215pub(crate) fn credential_store_flag() -> Option<CredentialStore> {
216    CREDENTIAL_STORE_FLAG.with(|f| decode_store(f.get()))
217}
218
219/// Derives the credential-store override env var from an app id, e.g.
220/// `godaddy` -> `GODADDY_CREDENTIAL_STORE`.
221#[must_use]
222pub fn credential_store_env_var(app_id: &str) -> String {
223    format!(
224        "{}_CREDENTIAL_STORE",
225        crate::flags::app_id_env_prefix(app_id)
226    )
227}
228
229/// Returns the path to the engine config file for `app_id`, if a base config
230/// directory can be resolved and `app_id` is a safe single path component.
231#[must_use]
232pub fn config_file_path(app_id: &str) -> Option<PathBuf> {
233    if !crate::fs::is_safe_path_component(app_id) {
234        tracing::warn!(app_id, "refusing config path with unsafe app id");
235        return None;
236    }
237    crate::fs::config_base_dir().map(|base| base.join(app_id).join("config.toml"))
238}
239
240/// Loads the engine-reserved config for `app_id`.
241///
242/// Convenience wrapper over [`ConfigFile::load`] + [`ConfigFile::engine`].
243/// Best-effort: a missing/unreadable/malformed file yields
244/// [`EngineConfig::default`], so a broken config file cannot take the CLI down.
245#[must_use]
246pub fn load(app_id: &str) -> EngineConfig {
247    ConfigFile::load(app_id).engine()
248}
249
250/// A loaded per-application config file.
251///
252/// cli-engine reads a single TOML file at `<config-base>/<app_id>/config.toml`
253/// (see [`config_file_path`]). Engine-reserved settings live in documented
254/// top-level tables (today just `[credentials]`, see [`EngineConfig`]); consumer
255/// CLIs own every other top-level table and read them with [`section`] or
256/// [`deserialize`]. The file is also surfaced to command handlers via
257/// [`CommandContext::config`](crate::command::CommandContext::config) and to
258/// module registration via
259/// [`ModuleContext::config`](crate::module::ModuleContext::config).
260///
261/// Edits made with [`set`] preserve existing comments and formatting (backed by
262/// `toml_edit`) and are persisted with [`save`].
263///
264/// [`section`]: ConfigFile::section
265/// [`deserialize`]: ConfigFile::deserialize
266/// [`set`]: ConfigFile::set
267/// [`save`]: ConfigFile::save
268#[derive(Clone, Debug)]
269pub struct ConfigFile {
270    path: Option<PathBuf>,
271    doc: DocumentMut,
272}
273
274impl Default for ConfigFile {
275    fn default() -> Self {
276        Self::from_doc(None, DocumentMut::new())
277    }
278}
279
280impl ConfigFile {
281    fn from_doc(path: Option<PathBuf>, doc: DocumentMut) -> Self {
282        Self { path, doc }
283    }
284
285    /// Loads the config file for `app_id`.
286    ///
287    /// Best-effort: a missing file, unresolvable config directory, or malformed
288    /// TOML yields an empty document (a warning is logged for the malformed
289    /// case). The resolved path is retained for [`save`](ConfigFile::save) even
290    /// when the file does not yet exist.
291    ///
292    /// **Blocking**: this function performs synchronous filesystem I/O. The
293    /// engine calls it once at `Cli::new` time (outside of command execution),
294    /// which is acceptable for a one-shot CLI. Avoid calling it from hot paths
295    /// or from within an async executor without `spawn_blocking`.
296    #[must_use]
297    pub fn load(app_id: &str) -> Self {
298        let path = config_file_path(app_id);
299        let doc = match &path {
300            None => DocumentMut::new(),
301            Some(p) => match std::fs::read_to_string(p) {
302                Ok(contents) => contents.parse::<DocumentMut>().unwrap_or_else(|e| {
303                    tracing::warn!(path = %p.display(), error = %e, "ignoring malformed config file");
304                    DocumentMut::new()
305                }),
306                Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
307                Err(e) => {
308                    tracing::warn!(path = %p.display(), error = %e, "could not read config file");
309                    DocumentMut::new()
310                }
311            },
312        };
313        Self::from_doc(path, doc)
314    }
315
316    /// Returns the resolved config file path, if a config directory was
317    /// available. `None` means neither `XDG_CONFIG_HOME`/`HOME` nor `APPDATA`
318    /// resolved to an absolute path (so nothing can be loaded or saved).
319    #[must_use]
320    pub fn path(&self) -> Option<&Path> {
321        self.path.as_deref()
322    }
323
324    /// Deserializes the engine-reserved sections into an [`EngineConfig`].
325    ///
326    /// Lenient: any deserialization error (for example an invalid
327    /// `[credentials].store`) yields [`EngineConfig::default`].
328    #[must_use]
329    pub fn engine(&self) -> EngineConfig {
330        self.deserialize().unwrap_or_default()
331    }
332
333    /// Deserializes a single top-level table `name` into `T`, or `Ok(None)` when
334    /// the key is absent.
335    ///
336    /// Use this to read a consumer-owned section such as `[deploy]`:
337    /// `cfg.section::<DeployConfig>("deploy")?`.
338    ///
339    /// # Errors
340    /// Returns an error when the table is present but does not deserialize into
341    /// `T`.
342    pub fn section<T: DeserializeOwned>(&self, name: &str) -> crate::Result<Option<T>> {
343        let item = match self.doc.get(name) {
344            None => return Ok(None),
345            Some(item) => item,
346        };
347        // Extract the section's key-value pairs into a temporary root-level
348        // document so `from_document` sees a plain `T`-shaped struct.
349        let mut tmp = DocumentMut::new();
350        if let Some(tbl) = item.as_table_like() {
351            for (k, v) in tbl.iter() {
352                tmp[k] = v.clone();
353            }
354        }
355        toml_edit::de::from_document::<T>(tmp)
356            .map(Some)
357            .map_err(|e| CliCoreError::message(format!("config section {name:?}: {e}")))
358    }
359
360    /// Deserializes the entire config file into a consumer root type `T`.
361    ///
362    /// The root type may include the engine-reserved sections alongside its own;
363    /// unknown keys are tolerated when `T` allows them.
364    ///
365    /// # Errors
366    /// Returns an error when the document does not deserialize into `T`.
367    pub fn deserialize<T: DeserializeOwned>(&self) -> crate::Result<T> {
368        toml_edit::de::from_document::<T>(self.doc.clone())
369            .map_err(|e| CliCoreError::message(format!("config deserialize error: {e}")))
370    }
371
372    /// Returns the string form of the value at a dotted key (for example
373    /// `credentials.store` or `deploy.region`), or `None` when absent.
374    ///
375    /// Scalars render without quotes; a table renders as its TOML fragment.
376    #[must_use]
377    pub fn get(&self, dotted_key: &str) -> Option<String> {
378        let mut item = self.doc.as_item();
379        for segment in dotted_key.split('.') {
380            item = item.as_table_like()?.get(segment)?;
381        }
382        match item.as_value() {
383            Some(toml_edit::Value::String(s)) => Some(s.value().clone()),
384            Some(other) => Some(other.to_string().trim().to_owned()),
385            None => Some(item.to_string()),
386        }
387    }
388
389    /// Sets the value at a dotted key, creating intermediate tables as needed.
390    ///
391    /// `value` is coerced to a TOML scalar type using these rules (in order):
392    /// 1. `"true"` / `"false"` (case-sensitive) → TOML boolean.
393    /// 2. Any string parseable as an `i64` → TOML integer.
394    /// 3. Any string parseable as an `f64` (including `"1e5"`, `"inf"`,
395    ///    `"nan"`) → TOML float.
396    /// 4. Everything else → TOML string.
397    ///
398    /// To force a value to be stored as a string when it looks numeric (e.g.
399    /// a version like `"1.0"`), this API does not currently support quoting —
400    /// wrap the value in the config file by hand.
401    ///
402    /// The engine-reserved `[credentials]` and `[output]` tables are
403    /// validated: only their known keys (`credentials.store`,
404    /// `output.format`) are accepted; unknown keys in those tables are
405    /// rejected. Existing comments and formatting elsewhere in the file are
406    /// preserved. Call [`save`](ConfigFile::save) to persist.
407    ///
408    /// # Errors
409    /// Returns an error for an empty/invalid key, an unknown engine-reserved
410    /// key, an invalid engine value, or a key whose parent path is not a
411    /// table.
412    pub fn set(&mut self, dotted_key: &str, value: &str) -> crate::Result<()> {
413        // Validate engine-reserved keys under [credentials] and [output].
414        // Only the documented key in each table is accepted; any other key
415        // in those tables is rejected to prevent silently writing unknown
416        // engine config that would be ignored (and confuse the user).
417        const ENGINE_RESERVED_TABLES: &[&str] = &["credentials", "output"];
418        let first_segment = dotted_key.split('.').next().unwrap_or("");
419        if ENGINE_RESERVED_TABLES.contains(&first_segment) {
420            match dotted_key {
421                "credentials.store" => {
422                    value
423                        .parse::<CredentialStore>()
424                        .map_err(|e| CliCoreError::message(e.to_string()))?;
425                }
426                "output.format" => {
427                    if !crate::output::is_valid_output_format(&value.trim().to_ascii_lowercase()) {
428                        return Err(CliCoreError::message(format!(
429                            "invalid output format {value:?} (expected one of: json, human, toon)"
430                        )));
431                    }
432                }
433                other => {
434                    return Err(CliCoreError::message(format!(
435                        "unknown engine-reserved key {other:?}; the only supported keys are \
436                         \"credentials.store\" and \"output.format\""
437                    )));
438                }
439            }
440        }
441        let segments: Vec<&str> = dotted_key.split('.').collect();
442        if segments.iter().any(|s| s.is_empty()) {
443            return Err(CliCoreError::message(format!(
444                "invalid config key {dotted_key:?}"
445            )));
446        }
447        let Some((last, parents)) = segments.split_last() else {
448            return Err(CliCoreError::message("empty config key"));
449        };
450        let mut table = self.doc.as_table_mut();
451        for segment in parents {
452            let entry = table
453                .entry(segment)
454                .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
455            table = entry.as_table_mut().ok_or_else(|| {
456                CliCoreError::message(format!("config key {segment:?} is not a table"))
457            })?;
458        }
459        table[last] = toml_edit::Item::Value(infer_toml_value(value));
460        Ok(())
461    }
462
463    /// Renders the whole config document back to a TOML string (preserving
464    /// comments and formatting).
465    #[must_use]
466    pub fn to_toml_string(&self) -> String {
467        self.doc.to_string()
468    }
469
470    /// Persists the document to its config path via an atomic write.
471    ///
472    /// # Errors
473    /// Returns an error when no config path is available (no resolvable config
474    /// directory) or the write fails.
475    pub fn save(&self) -> crate::Result<()> {
476        let path = self.path.as_ref().ok_or_else(|| {
477            CliCoreError::message(
478                "no config path available (set XDG_CONFIG_HOME, HOME, or %APPDATA% \
479                 to a directory)",
480            )
481        })?;
482        crate::fs::write_string_atomic(path, &self.doc.to_string())
483    }
484}
485
486/// Parses `value` as a TOML bool/integer/float when possible, else a string.
487fn infer_toml_value(value: &str) -> toml_edit::Value {
488    if let Ok(b) = value.parse::<bool>() {
489        return b.into();
490    }
491    if let Ok(i) = value.parse::<i64>() {
492        return i.into();
493    }
494    if let Ok(f) = value.parse::<f64>() {
495        return f.into();
496    }
497    value.into()
498}
499
500/// Resolves the effective [`CredentialStore`] from explicit inputs.
501///
502/// Pure and side-effect free so the precedence is unit-testable without touching
503/// process state. Precedence (highest first): CLI `flag`, then `env` (an invalid
504/// value is logged and ignored, falling through), then the config `file`, then
505/// the default [`CredentialStore::Keyring`].
506#[must_use]
507pub fn resolve_credential_store_with(
508    flag: Option<CredentialStore>,
509    env: Option<&str>,
510    file: &EngineConfig,
511) -> CredentialStore {
512    if let Some(store) = flag {
513        return store;
514    }
515    if let Some(raw) = env {
516        match raw.parse::<CredentialStore>() {
517            Ok(store) => return store,
518            Err(e) => tracing::warn!(error = %e, "ignoring invalid credential-store env var"),
519        }
520    }
521    if let Some(store) = file.credentials.store {
522        return store;
523    }
524    CredentialStore::default()
525}
526
527/// Resolves the effective [`CredentialStore`] for `app_id` against process state.
528///
529/// Reads the CLI-flag override (`credential_store_flag`), the
530/// `${PREFIX}_CREDENTIAL_STORE` env var via the injected `var` getter, and the
531/// config file ([`load`]), then applies [`resolve_credential_store_with`]. The
532/// `var` getter is injected so callers/tests can supply environment lookups
533/// without mutating the process environment.
534pub fn resolve_credential_store(
535    app_id: &str,
536    var: impl Fn(&str) -> Option<String>,
537) -> CredentialStore {
538    let env = var(&credential_store_env_var(app_id));
539    let file = load(app_id);
540    resolve_credential_store_with(credential_store_flag(), env.as_deref(), &file)
541}
542
543/// Test-only helpers for serializing and mutating `XDG_CONFIG_HOME`.
544///
545/// `set_var`/`remove_var` are `unsafe` in the Rust 2024 edition; [`XDG_TEST_MUTEX`]
546/// serializes all access so usage here is data-race-free. Shared crate-wide so
547/// every test that mutates `XDG_CONFIG_HOME` (in `config`, `auth::storage`, and
548/// `auth::pkce`) contends on the *same* lock rather than racing across modules.
549#[cfg(test)]
550#[allow(unsafe_code, dead_code)]
551pub(crate) mod test_env {
552    use std::path::Path;
553    use std::sync::{Mutex, MutexGuard};
554
555    /// Serializes access to `XDG_CONFIG_HOME` across all crate tests.
556    pub(crate) static XDG_TEST_MUTEX: Mutex<()> = Mutex::new(());
557
558    /// Acquires the shared lock (poison-tolerant). Hold it for the entire span
559    /// during which `XDG_CONFIG_HOME` is mutated — including across `.await`
560    /// points in async tests (`#[tokio::test]` uses a current-thread runtime,
561    /// so the non-`Send` guard is fine).
562    pub(crate) fn lock() -> MutexGuard<'static, ()> {
563        XDG_TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner())
564    }
565
566    /// RAII guard that restores an env var to its prior value when dropped,
567    /// including on panic. The caller must hold [`lock`] for the guard's life.
568    pub(crate) struct EnvVarGuard {
569        key: &'static str,
570        prev: Option<String>,
571    }
572
573    impl EnvVarGuard {
574        /// Sets `key` to `value` (or removes it when `None`), capturing the
575        /// prior value for restoration on drop. Caller must hold [`lock`].
576        pub(crate) fn set(key: &'static str, value: Option<&Path>) -> Self {
577            let prev = std::env::var(key).ok();
578            // SAFETY: caller holds XDG_TEST_MUTEX, serializing all mutation.
579            unsafe {
580                match value {
581                    Some(v) => std::env::set_var(key, v),
582                    None => std::env::remove_var(key),
583                }
584            }
585            Self { key, prev }
586        }
587    }
588
589    impl Drop for EnvVarGuard {
590        fn drop(&mut self) {
591            // SAFETY: callers hold XDG_TEST_MUTEX for the guard's lifetime.
592            unsafe {
593                match self.prev.take() {
594                    Some(v) => std::env::set_var(self.key, v),
595                    None => std::env::remove_var(self.key),
596                }
597            }
598        }
599    }
600
601    /// Runs `f` with `XDG_CONFIG_HOME` set to `value`, holding the shared lock
602    /// and restoring the previous value afterward.
603    pub(crate) fn with_xdg_config_home<F: FnOnce() -> R, R>(value: &Path, f: F) -> R {
604        let _lock = lock();
605        let _restore = EnvVarGuard::set("XDG_CONFIG_HOME", Some(value));
606        f()
607    }
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613
614    #[test]
615    fn parses_known_variants_case_insensitively() {
616        assert_eq!("auto".parse(), Ok(CredentialStore::Auto));
617        assert_eq!("Keyring".parse(), Ok(CredentialStore::Keyring));
618        assert_eq!("KEYCHAIN".parse(), Ok(CredentialStore::Keyring));
619        assert_eq!("  file  ".parse(), Ok(CredentialStore::File));
620    }
621
622    #[test]
623    fn rejects_unknown_variant() {
624        let err = "vault"
625            .parse::<CredentialStore>()
626            .expect_err("should reject");
627        assert!(err.to_string().contains("vault"));
628    }
629
630    #[test]
631    fn display_round_trips_through_from_str() {
632        for store in [
633            CredentialStore::Auto,
634            CredentialStore::Keyring,
635            CredentialStore::File,
636        ] {
637            assert_eq!(store.to_string().parse(), Ok(store));
638        }
639    }
640
641    #[test]
642    fn env_var_name_is_derived_from_app_id() {
643        assert_eq!(
644            credential_store_env_var("godaddy"),
645            "GODADDY_CREDENTIAL_STORE"
646        );
647        assert_eq!(
648            credential_store_env_var("my-cli"),
649            "MY_CLI_CREDENTIAL_STORE"
650        );
651    }
652
653    #[test]
654    fn deserializes_store_from_toml() {
655        let config: EngineConfig =
656            toml_edit::de::from_str("[credentials]\nstore = \"file\"\n").expect("valid toml");
657        assert_eq!(config.credentials.store, Some(CredentialStore::File));
658    }
659
660    #[test]
661    fn deserialize_rejects_bad_store_value() {
662        let result = toml_edit::de::from_str::<EngineConfig>("[credentials]\nstore = \"nope\"\n");
663        assert!(result.is_err(), "bad store value should fail to parse");
664    }
665
666    #[test]
667    fn unknown_keys_are_ignored() {
668        let config: EngineConfig =
669            toml_edit::de::from_str("future_section = true\n[credentials]\nstore = \"auto\"\n")
670                .expect("unknown keys tolerated");
671        assert_eq!(config.credentials.store, Some(CredentialStore::Auto));
672    }
673
674    #[test]
675    fn resolution_precedence_flag_beats_env_beats_file() {
676        let file = EngineConfig {
677            credentials: CredentialsConfig {
678                store: Some(CredentialStore::Keyring),
679            },
680            ..Default::default()
681        };
682        // flag wins over everything
683        assert_eq!(
684            resolve_credential_store_with(Some(CredentialStore::Auto), Some("file"), &file),
685            CredentialStore::Auto
686        );
687        // env wins over file
688        assert_eq!(
689            resolve_credential_store_with(None, Some("file"), &file),
690            CredentialStore::File
691        );
692        // file wins over default
693        assert_eq!(
694            resolve_credential_store_with(None, None, &file),
695            CredentialStore::Keyring
696        );
697    }
698
699    #[test]
700    fn resolution_defaults_to_auto() {
701        assert_eq!(
702            resolve_credential_store_with(None, None, &EngineConfig::default()),
703            CredentialStore::Auto
704        );
705    }
706
707    #[test]
708    fn resolution_ignores_invalid_env_and_falls_through() {
709        let file = EngineConfig {
710            credentials: CredentialsConfig {
711                store: Some(CredentialStore::File),
712            },
713            ..Default::default()
714        };
715        // invalid env is ignored, so the file value applies
716        assert_eq!(
717            resolve_credential_store_with(None, Some("garbage"), &file),
718            CredentialStore::File
719        );
720        // invalid env with no file falls through to the default
721        assert_eq!(
722            resolve_credential_store_with(None, Some("garbage"), &EngineConfig::default()),
723            CredentialStore::Auto
724        );
725    }
726
727    #[test]
728    fn config_file_path_rejects_unsafe_app_id() {
729        assert_eq!(config_file_path("../evil"), None);
730        assert_eq!(config_file_path("a/b"), None);
731    }
732
733    #[test]
734    fn credential_store_flag_encodes_round_trips() {
735        for store in [
736            None,
737            Some(CredentialStore::Auto),
738            Some(CredentialStore::Keyring),
739            Some(CredentialStore::File),
740        ] {
741            assert_eq!(decode_store(encode_store(store)), store);
742        }
743    }
744
745    #[test]
746    fn config_file_path_uses_xdg_config_home() {
747        let dir = std::env::temp_dir().join("cli-engine-config-path-test");
748        test_env::with_xdg_config_home(&dir, || {
749            assert_eq!(
750                config_file_path("myapp"),
751                Some(dir.join("myapp").join("config.toml"))
752            );
753        });
754    }
755
756    #[derive(Debug, Deserialize, PartialEq)]
757    struct Deploy {
758        region: String,
759        replicas: u32,
760    }
761
762    fn doc_config(toml: &str) -> ConfigFile {
763        ConfigFile::from_doc(None, toml.parse().expect("valid toml"))
764    }
765
766    #[test]
767    fn section_reads_consumer_table() {
768        let cfg = doc_config("[deploy]\nregion = \"us-west\"\nreplicas = 3\n");
769        let deploy: Deploy = cfg.section("deploy").expect("ok").expect("present");
770        assert_eq!(
771            deploy,
772            Deploy {
773                region: "us-west".to_owned(),
774                replicas: 3
775            }
776        );
777        assert!(cfg.section::<Deploy>("absent").expect("ok").is_none());
778    }
779
780    #[test]
781    fn engine_and_consumer_sections_coexist() {
782        let cfg = doc_config(
783            "[credentials]\nstore = \"file\"\n[deploy]\nregion = \"eu\"\nreplicas = 1\n",
784        );
785        assert_eq!(cfg.engine().credentials.store, Some(CredentialStore::File));
786        assert_eq!(
787            cfg.section::<Deploy>("deploy")
788                .expect("ok")
789                .expect("present")
790                .region,
791            "eu"
792        );
793    }
794
795    #[test]
796    fn get_reads_dotted_scalar() {
797        let cfg = doc_config("[credentials]\nstore = \"file\"\n[deploy]\nreplicas = 3\n");
798        assert_eq!(cfg.get("credentials.store").as_deref(), Some("file"));
799        assert_eq!(cfg.get("deploy.replicas").as_deref(), Some("3"));
800        assert_eq!(cfg.get("deploy.missing"), None);
801        assert_eq!(cfg.get("nope.at.all"), None);
802    }
803
804    #[test]
805    fn set_infers_scalar_types() {
806        let mut cfg = ConfigFile::default();
807        cfg.set("telemetry.enabled", "true").expect("set bool");
808        cfg.set("deploy.replicas", "5").expect("set int");
809        cfg.set("deploy.region", "us-west").expect("set str");
810        assert_eq!(cfg.get("telemetry.enabled").as_deref(), Some("true"));
811        assert_eq!(cfg.get("deploy.replicas").as_deref(), Some("5"));
812        assert_eq!(cfg.get("deploy.region").as_deref(), Some("us-west"));
813        // bool/int stored as scalars, not quoted strings
814        assert!(cfg.doc.to_string().contains("enabled = true"));
815        assert!(cfg.doc.to_string().contains("replicas = 5"));
816    }
817
818    #[test]
819    fn set_validates_engine_store_key() {
820        let mut cfg = ConfigFile::default();
821        assert!(cfg.set("credentials.store", "bogus").is_err());
822        assert!(cfg.set("credentials.store", "file").is_ok());
823        assert_eq!(cfg.engine().credentials.store, Some(CredentialStore::File));
824    }
825
826    #[test]
827    fn set_rejects_unknown_engine_reserved_keys() {
828        let mut cfg = ConfigFile::default();
829        // Unknown keys in [credentials] are rejected to prevent silent no-ops.
830        assert!(
831            cfg.set("credentials.unknown_future_key", "foo").is_err(),
832            "unknown credentials key should be rejected"
833        );
834        assert!(
835            cfg.set("credentials.timeout", "30").is_err(),
836            "unknown credentials.timeout should be rejected"
837        );
838        // Consumer-owned tables are unrestricted.
839        assert!(
840            cfg.set("deploy.region", "us-west").is_ok(),
841            "consumer-owned keys should be accepted"
842        );
843    }
844
845    #[test]
846    fn set_rejects_empty_key_segments() {
847        let mut cfg = ConfigFile::default();
848        assert!(cfg.set("a..b", "x").is_err());
849        assert!(cfg.set("", "x").is_err());
850    }
851
852    #[test]
853    fn set_preserves_comments_and_other_tables() {
854        let mut cfg =
855            doc_config("# keep me\n[credentials]\nstore = \"file\"\n\n[deploy]\nregion = \"us\"\n");
856        cfg.set("deploy.region", "eu").expect("set");
857        let rendered = cfg.doc.to_string();
858        assert!(
859            rendered.contains("# keep me"),
860            "comment preserved: {rendered}"
861        );
862        assert!(
863            rendered.contains("store = \"file\""),
864            "other table preserved"
865        );
866        assert!(rendered.contains("region = \"eu\""), "value updated");
867    }
868
869    #[test]
870    fn load_and_save_round_trip() {
871        let dir = tempfile::tempdir().expect("tempdir");
872        test_env::with_xdg_config_home(dir.path(), || {
873            let mut cfg = ConfigFile::load("roundtrip");
874            assert!(cfg.path().is_some());
875            cfg.set("deploy.region", "us-west").expect("set");
876            cfg.save().expect("save");
877            // Reload from disk and confirm persistence.
878            let reloaded = ConfigFile::load("roundtrip");
879            assert_eq!(reloaded.get("deploy.region").as_deref(), Some("us-west"));
880        });
881    }
882
883    #[test]
884    fn malformed_file_loads_as_empty() {
885        let dir = tempfile::tempdir().expect("tempdir");
886        test_env::with_xdg_config_home(dir.path(), || {
887            let path = config_file_path("broken").expect("path");
888            std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
889            std::fs::write(&path, "not = valid = toml").expect("write");
890            let cfg = ConfigFile::load("broken");
891            assert_eq!(cfg.engine().credentials.store, None);
892            assert_eq!(cfg.get("anything"), None);
893        });
894    }
895
896    #[test]
897    fn default_config_has_no_path_and_save_errors() {
898        let cfg = ConfigFile::default();
899        assert!(cfg.path().is_none());
900        assert!(cfg.save().is_err(), "save without a path should error");
901    }
902}