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