Skip to main content

omni_dev/utils/
settings.rs

1//! Settings and configuration utilities.
2//!
3//! This module provides functionality to read settings from $HOME/.omni-dev/settings.json
4//! and use them as a fallback for environment variables.
5//!
6//! It also owns the write side: [`Settings::upsert_env_vars_in`] and
7//! [`Settings::remove_env_vars_in`] (plus their base-`env` shorthands
8//! [`Settings::upsert_env_vars`] / [`Settings::remove_env_vars`]) are the only
9//! production paths that mutate the settings file. Writes target the active
10//! profile's `env` when a profile is given, mirroring the read-side isolation
11//! of [`Settings::resolve_with`] (issue #1116). Because the `env` maps hold
12//! credentials (Atlassian, Datadog), every write is hardened: parent directory
13//! `0700`, file `0600`, re-tightened on each write (issue #1128).
14
15use std::collections::{BTreeSet, HashMap};
16use std::fmt;
17use std::fs;
18use std::path::{Path, PathBuf};
19use std::sync::Mutex;
20
21use anyhow::{Context, Result};
22use serde::Deserialize;
23
24use crate::utils::env::{EnvSource, SystemEnv};
25
26/// Where a resolved environment value came from, for provenance reporting
27/// (issue #1143).
28///
29/// An ambient setting — a shell export or a `settings.json` `env` entry — is
30/// sticky across invocations, so warnings about security-sensitive values
31/// (e.g. the claude-cli escape hatches) name the source to distinguish a
32/// deliberate one-off flag from a forgotten persistent setting.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum EnvValueSource {
35    /// Exported into the process environment by a command-line flag during
36    /// this invocation (see `Cli::propagate_global_flags`).
37    CliFlag,
38    /// The process environment (a shell export or inherited variable).
39    ProcessEnv,
40    /// The base `env` map in `$HOME/.omni-dev/settings.json`.
41    SettingsEnv,
42    /// The named profile's `env` map in `$HOME/.omni-dev/settings.json`.
43    SettingsProfile(String),
44}
45
46impl fmt::Display for EnvValueSource {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        match self {
49            Self::CliFlag => write!(f, "command-line flag"),
50            Self::ProcessEnv => write!(f, "process environment variable (e.g. a shell export)"),
51            Self::SettingsEnv => write!(f, "the env map in $HOME/.omni-dev/settings.json"),
52            Self::SettingsProfile(name) => {
53                write!(
54                    f,
55                    "the profile '{name}' env map in $HOME/.omni-dev/settings.json"
56                )
57            }
58        }
59    }
60}
61
62/// Env-var keys that `Cli::propagate_global_flags` exported from command-line
63/// flags this invocation. Additive-only, written once at startup, so readers
64/// can attribute a process-env hit to the flag that set it rather than to an
65/// ambient shell export. Not an env-mutation seam: tests exercise the sourced
66/// resolvers through their injected `from_cli_flag` parameter instead.
67static CLI_FLAG_EXPORTS: Mutex<BTreeSet<String>> = Mutex::new(BTreeSet::new());
68
69/// Records that `key` was exported into the process environment by a
70/// command-line flag, so [`get_env_var_sourced`] reports
71/// [`EnvValueSource::CliFlag`] for it instead of
72/// [`EnvValueSource::ProcessEnv`].
73pub fn note_cli_flag_export(key: &str) {
74    // Recover from poisoning rather than losing provenance: the set is
75    // insert-only, so a panicked writer cannot leave it inconsistent.
76    let mut set = CLI_FLAG_EXPORTS
77        .lock()
78        .unwrap_or_else(std::sync::PoisonError::into_inner);
79    set.insert(key.to_string());
80}
81
82/// Returns whether `key` was exported by a command-line flag this invocation.
83#[must_use]
84pub fn exported_by_cli_flag(key: &str) -> bool {
85    CLI_FLAG_EXPORTS
86        .lock()
87        .unwrap_or_else(std::sync::PoisonError::into_inner)
88        .contains(key)
89}
90
91/// Environment variable that selects the active profile, mirroring `AWS_PROFILE`.
92///
93/// Read from the **raw** process environment only (never through the profile
94/// fallback, which would be circular); the `--profile` flag propagates its value
95/// here in `Cli::propagate_global_flags`.
96pub const PROFILE_ENV_VAR: &str = "OMNI_DEV_PROFILE";
97
98/// A named credential/config bundle inside `settings.json` — its own `env` map,
99/// selected per invocation via `--profile` / `OMNI_DEV_PROFILE`.
100#[derive(Debug, Default, Deserialize)]
101pub struct Profile {
102    /// Environment variable overrides applied when this profile is active.
103    #[serde(default)]
104    pub env: HashMap<String, String>,
105}
106
107/// Settings loaded from $HOME/.omni-dev/settings.json.
108#[derive(Debug, Default, Deserialize)]
109pub struct Settings {
110    /// Environment variable overrides — the default bundle, consulted only when
111    /// **no** profile is active.
112    #[serde(default)]
113    pub env: HashMap<String, String>,
114
115    /// Named profiles. Selecting one replaces the base `env` in the fallback
116    /// chain (isolated / AWS-faithful); see [`Settings::resolve_with`].
117    #[serde(default)]
118    pub profiles: HashMap<String, Profile>,
119}
120
121/// Returns the active profile name from `raw` (the process environment), or
122/// `None` when `OMNI_DEV_PROFILE` is unset or empty.
123///
124/// Reads the **raw** env only, so it is pure over the injected source and never
125/// resolves through the profile fallback.
126pub fn active_profile_from<E: EnvSource>(raw: &E) -> Option<String> {
127    raw.var(PROFILE_ENV_VAR).filter(|s| !s.is_empty())
128}
129
130/// Renders ` (profile '<name>')` for credential-store CLI messages, or the
131/// empty string when no profile is active — so `auth login`/`logout` output
132/// names the env map it actually wrote to (issue #1116).
133#[must_use]
134pub fn profile_suffix(profile: Option<&str>) -> String {
135    profile.map_or_else(String::new, |name| format!(" (profile '{name}')"))
136}
137
138/// An [`EnvSource`](crate::utils::env::EnvSource) with the settings/profile
139/// fallback — the value form of [`get_env_var`].
140///
141/// Reads the real process environment first, then the active profile's `env`
142/// (or the base `env` when no profile is active) in
143/// `$HOME/.omni-dev/settings.json`.
144///
145/// Pass `&SettingsEnv::load()` from a thin production wrapper; tests inject a
146/// pure `MapEnv` into the same `*_with(&impl EnvSource, …)` seam instead of
147/// mutating the process environment.
148#[derive(Debug, Default)]
149pub struct SettingsEnv {
150    settings: Settings,
151    active_profile: Option<String>,
152}
153
154impl SettingsEnv {
155    /// Loads settings from the default location, falling back to an empty
156    /// settings map if they are absent or unreadable (env-only behaviour). The
157    /// active profile is read from `OMNI_DEV_PROFILE`.
158    pub fn load() -> Self {
159        Self::load_with_profile(active_profile_from(&SystemEnv).as_deref())
160    }
161
162    /// Like [`load`](Self::load) but with the active profile supplied
163    /// explicitly — for tests and embedders that select a profile without
164    /// setting `OMNI_DEV_PROFILE` in the process environment.
165    pub fn load_with_profile(profile: Option<&str>) -> Self {
166        Self {
167            settings: Settings::load().unwrap_or_default(),
168            active_profile: profile.map(str::to_string),
169        }
170    }
171}
172
173impl EnvSource for SettingsEnv {
174    fn var(&self, key: &str) -> Option<String> {
175        self.settings
176            .resolve_with(&SystemEnv, self.active_profile.as_deref(), key)
177    }
178}
179
180impl Settings {
181    /// Loads settings from the default location.
182    pub fn load() -> Result<Self> {
183        let settings_path = Self::get_settings_path()?;
184        Self::load_from_path(&settings_path)
185    }
186
187    /// Loads settings from a specific path.
188    pub fn load_from_path<P: AsRef<Path>>(path: P) -> Result<Self> {
189        let path = path.as_ref();
190
191        // If file doesn't exist, return default settings
192        if !path.exists() {
193            return Ok(Self::default());
194        }
195
196        // Read and parse the settings file
197        let content = fs::read_to_string(path)
198            .with_context(|| format!("Failed to read settings file: {}", path.display()))?;
199
200        serde_json::from_str::<Self>(&content)
201            .with_context(|| format!("Failed to parse settings file: {}", path.display()))
202    }
203
204    /// Returns the default settings path.
205    pub fn get_settings_path() -> Result<PathBuf> {
206        let home_dir = dirs::home_dir().context("Failed to determine home directory")?;
207
208        Ok(home_dir.join(".omni-dev").join("settings.json"))
209    }
210
211    /// Returns an environment variable with fallback to settings, honouring the
212    /// active profile from `OMNI_DEV_PROFILE`.
213    pub fn get_env_var(&self, key: &str) -> Option<String> {
214        self.resolve_with(&SystemEnv, active_profile_from(&SystemEnv).as_deref(), key)
215    }
216
217    /// Isolated / AWS-faithful resolution: `raw` (the process environment) wins;
218    /// then the active profile's `env` if `active` is set, else the base `env`.
219    /// The base map is **not** consulted when a profile is active, so a missing
220    /// key fails loud rather than silently reusing a default credential against
221    /// the wrong tenant.
222    ///
223    /// This is the pure seam: production wrappers pass `&SystemEnv`; tests pass
224    /// a `MapEnv` and an explicit `active`, mutating no process-global state.
225    pub fn resolve_with<E: EnvSource>(
226        &self,
227        raw: &E,
228        active: Option<&str>,
229        key: &str,
230    ) -> Option<String> {
231        self.resolve_with_source(raw, active, key)
232            .map(|(value, _)| value)
233    }
234
235    /// Like [`Settings::resolve_with`], but also reports which layer supplied
236    /// the value: the raw process environment, the active profile's `env`, or
237    /// the base `env` (issue #1143). Same precedence, same profile isolation.
238    ///
239    /// A [`EnvValueSource::CliFlag`] attribution is layered on top by
240    /// [`get_env_var_sourced`], which knows about flag exports; this resolver
241    /// only distinguishes what it can see.
242    pub fn resolve_with_source<E: EnvSource>(
243        &self,
244        raw: &E,
245        active: Option<&str>,
246        key: &str,
247    ) -> Option<(String, EnvValueSource)> {
248        if let Some(value) = raw.var(key) {
249            return Some((value, EnvValueSource::ProcessEnv));
250        }
251        match active {
252            Some(name) => self
253                .profiles
254                .get(name)
255                .and_then(|p| p.env.get(key).cloned())
256                .map(|value| (value, EnvValueSource::SettingsProfile(name.to_string()))),
257            None => self
258                .env
259                .get(key)
260                .cloned()
261                .map(|value| (value, EnvValueSource::SettingsEnv)),
262        }
263    }
264
265    /// Merges the given key/value pairs into the base `env` object of the
266    /// settings file at `path` — [`Settings::upsert_env_vars_in`] with no
267    /// profile.
268    pub fn upsert_env_vars(path: &Path, vars: &[(&str, &str)]) -> Result<()> {
269        Self::upsert_env_vars_in(path, None, vars)
270    }
271
272    /// Merges the given key/value pairs into the `env` object targeted by
273    /// `profile` — `profiles.<name>.env` when `Some`, the base `env` when
274    /// `None` — creating the file, its parent directory, and any missing
275    /// intermediate objects as needed. Writes therefore land where
276    /// [`Settings::resolve_with`] will look for them (issue #1116).
277    ///
278    /// A `profile` absent from the file is created rather than rejected; the
279    /// CLI validates the active profile before dispatch, so this only affects
280    /// library callers.
281    ///
282    /// The file is read and written as a generic JSON value, so every other
283    /// field (other profiles, unknown keys) is preserved verbatim. Because the
284    /// `env` maps hold credentials, the write is hardened: parent directory
285    /// `0700`, file `0600` (see [`write_settings`]).
286    pub fn upsert_env_vars_in(
287        path: &Path,
288        profile: Option<&str>,
289        vars: &[(&str, &str)],
290    ) -> Result<()> {
291        let mut settings_value = read_or_default_settings(path)?;
292
293        let env = ensure_env_object(&mut settings_value, profile)?;
294        for (key, value) in vars {
295            env.insert(
296                (*key).to_string(),
297                serde_json::Value::String((*value).to_string()),
298            );
299        }
300
301        write_settings(path, &settings_value)
302    }
303
304    /// Removes the given keys from the base `env` object of the settings file
305    /// at `path` — [`Settings::remove_env_vars_in`] with no profile.
306    pub fn remove_env_vars(path: &Path, keys: &[&str]) -> Result<bool> {
307        Self::remove_env_vars_in(path, None, keys)
308    }
309
310    /// Removes the given keys from the `env` object targeted by `profile`
311    /// (`profiles.<name>.env` when `Some`, the base `env` when `None`),
312    /// leaving all other settings — including the same keys in other env
313    /// maps — intact.
314    ///
315    /// Returns `true` if any key was present in the targeted map and removed
316    /// (the file is rewritten, hardened as in
317    /// [`Settings::upsert_env_vars_in`]), `false` when the file did not
318    /// exist, the targeted map was absent, or it contained none of the keys
319    /// (the file is left untouched).
320    pub fn remove_env_vars_in(path: &Path, profile: Option<&str>, keys: &[&str]) -> Result<bool> {
321        if !path.exists() {
322            return Ok(false);
323        }
324        let mut settings_value = read_or_default_settings(path)?;
325
326        let mut removed = false;
327        if let Some(env) = env_object_mut(&mut settings_value, profile) {
328            for key in keys {
329                if env.remove(*key).is_some() {
330                    removed = true;
331                }
332            }
333        }
334
335        if removed {
336            write_settings(path, &settings_value)?;
337        }
338        Ok(removed)
339    }
340
341    /// Validates that `name` is a known profile, returning a hard error that
342    /// lists the known profiles (sorted) otherwise. Called once at the CLI
343    /// boundary so a typo never silently falls back to base credentials.
344    pub fn validate_profile(&self, name: &str) -> Result<()> {
345        if self.profiles.contains_key(name) {
346            return Ok(());
347        }
348        let known = if self.profiles.is_empty() {
349            "(none)".to_string()
350        } else {
351            let mut names: Vec<&str> = self.profiles.keys().map(String::as_str).collect();
352            names.sort_unstable();
353            names.join(", ")
354        };
355        Err(anyhow::anyhow!(
356            "unknown profile '{name}'; known profiles: {known}"
357        ))
358    }
359}
360
361/// Navigates `root` to the env object targeted by `profile` — the base `env`
362/// when `None`, `profiles.<name>.env` when `Some` — creating missing
363/// intermediate objects and replacing non-object nodes along the way.
364/// The creating counterpart of [`env_object_mut`], for upserts.
365fn ensure_env_object<'a>(
366    root: &'a mut serde_json::Value,
367    profile: Option<&str>,
368) -> Result<&'a mut serde_json::Map<String, serde_json::Value>> {
369    let parent = match profile {
370        Some(name) => {
371            if !root
372                .get("profiles")
373                .is_some_and(serde_json::Value::is_object)
374            {
375                root["profiles"] = serde_json::json!({});
376            }
377            let profiles = &mut root["profiles"];
378            if !profiles.get(name).is_some_and(serde_json::Value::is_object) {
379                profiles[name] = serde_json::json!({});
380            }
381            &mut profiles[name]
382        }
383        None => root,
384    };
385
386    if !parent.get("env").is_some_and(serde_json::Value::is_object) {
387        parent["env"] = serde_json::json!({});
388    }
389    parent["env"]
390        .as_object_mut()
391        .context("Internal error: env key is not an object after initialization")
392}
393
394/// Navigates `root` to the env object targeted by `profile`, or `None` when
395/// any node on the way is absent or not an object. The non-creating
396/// counterpart of [`ensure_env_object`], for removals.
397fn env_object_mut<'a>(
398    root: &'a mut serde_json::Value,
399    profile: Option<&str>,
400) -> Option<&'a mut serde_json::Map<String, serde_json::Value>> {
401    let parent = match profile {
402        Some(name) => root.get_mut("profiles")?.get_mut(name)?,
403        None => root,
404    };
405    parent
406        .get_mut("env")
407        .and_then(serde_json::Value::as_object_mut)
408}
409
410/// Reads and parses the settings file at `path` as a generic JSON value
411/// (preserving unknown fields), or returns `{}` when the file does not exist.
412fn read_or_default_settings(path: &Path) -> Result<serde_json::Value> {
413    if path.exists() {
414        let content = fs::read_to_string(path)
415            .with_context(|| format!("Failed to read {}", path.display()))?;
416        serde_json::from_str(&content)
417            .with_context(|| format!("Failed to parse {}", path.display()))
418    } else {
419        Ok(serde_json::json!({}))
420    }
421}
422
423/// The single hardened write site for the settings file: creates the parent
424/// directory `0700`, writes the pretty-printed JSON through a `0600` handle
425/// (no window where a fresh file is world-readable), and re-tightens a
426/// pre-existing looser-permission file on every write (issue #1128).
427fn write_settings(path: &Path, value: &serde_json::Value) -> Result<()> {
428    if let Some(parent) = path.parent() {
429        if !parent.as_os_str().is_empty() {
430            crate::daemon::paths::ensure_dir_0700(parent)?;
431        }
432    }
433    let formatted =
434        serde_json::to_string_pretty(value).context("Failed to serialize settings JSON")?;
435    write_file_0600(path, &formatted)
436        .with_context(|| format!("Failed to write {}", path.display()))?;
437    crate::daemon::paths::set_file_0600(path)?;
438    Ok(())
439}
440
441/// Creates/truncates `path` with owner-only (`0600`) permissions on Unix.
442#[cfg(unix)]
443fn write_file_0600(path: &Path, contents: &str) -> std::io::Result<()> {
444    use std::io::Write;
445    use std::os::unix::fs::OpenOptionsExt;
446
447    let mut file = fs::OpenOptions::new()
448        .write(true)
449        .create(true)
450        .truncate(true)
451        .mode(0o600)
452        .open(path)?;
453    file.write_all(contents.as_bytes())
454}
455
456/// Non-Unix fallback: a plain write ([`set_file_0600`](crate::daemon::paths::set_file_0600)
457/// is a no-op there too).
458#[cfg(not(unix))]
459fn write_file_0600(path: &Path, contents: &str) -> std::io::Result<()> {
460    fs::write(path, contents)
461}
462
463/// Returns an environment variable with fallback to settings, honouring the
464/// active profile from `OMNI_DEV_PROFILE`.
465pub fn get_env_var(key: &str) -> Result<String> {
466    get_env_var_with(&SystemEnv, Settings::load, key)
467}
468
469/// Like [`get_env_var`], but also reports where the value came from.
470///
471/// The source is a command-line flag export, the process environment, or a
472/// settings.json `env` map (issue #1143) — for warnings about
473/// security-sensitive values (e.g. the claude-cli escape hatches) that
474/// should name their source.
475pub fn get_env_var_sourced(key: &str) -> Result<(String, EnvValueSource)> {
476    get_env_var_sourced_with(&SystemEnv, Settings::load, exported_by_cli_flag(key), key)
477}
478
479/// Pure core of [`get_env_var`]: [`get_env_var_sourced_with`] with the source
480/// dropped.
481fn get_env_var_with<E, F>(env: &E, load: F, key: &str) -> Result<String>
482where
483    E: EnvSource,
484    F: FnOnce() -> Result<Settings>,
485{
486    get_env_var_sourced_with(env, load, false, key).map(|(value, _)| value)
487}
488
489/// Pure core of [`get_env_var_sourced`]: `env` is the raw source, `load`
490/// produces the settings lazily — it is invoked only on a raw-env miss,
491/// preserving the no-disk fast path — and `from_cli_flag` says whether a flag
492/// exported `key` this invocation (injected so tests never touch the
493/// process-global flag registry). Tests inject a `MapEnv` and a closure
494/// returning `Ok`/`Err` to cover both the resolved and load-failure branches
495/// without touching disk.
496fn get_env_var_sourced_with<E, F>(
497    env: &E,
498    load: F,
499    from_cli_flag: bool,
500    key: &str,
501) -> Result<(String, EnvValueSource)>
502where
503    E: EnvSource,
504    F: FnOnce() -> Result<Settings>,
505{
506    // A raw process-env hit short-circuits without loading settings from disk.
507    // A flag export always lands in the process env, so the flag attribution
508    // only ever applies on this branch.
509    if let Some(value) = env.var(key) {
510        let source = if from_cli_flag {
511            EnvValueSource::CliFlag
512        } else {
513            EnvValueSource::ProcessEnv
514        };
515        return Ok((value, source));
516    }
517    match load() {
518        Ok(settings) => settings
519            .resolve_with_source(env, active_profile_from(env).as_deref(), key)
520            .ok_or_else(|| anyhow::anyhow!("Environment variable not found: {key}")),
521        Err(err) => {
522            // If we couldn't load settings, just return the original env var error
523            Err(anyhow::anyhow!("Environment variable not found: {key}").context(err))
524        }
525    }
526}
527
528/// Tries multiple environment variables with fallback to settings.
529pub fn get_env_vars(keys: &[&str]) -> Result<String> {
530    for key in keys {
531        if let Ok(value) = get_env_var(key) {
532            return Ok(value);
533        }
534    }
535
536    Err(anyhow::anyhow!(
537        "None of the environment variables found: {keys:?}"
538    ))
539}
540
541#[cfg(test)]
542#[allow(clippy::unwrap_used, clippy::expect_used)]
543mod tests {
544    use super::*;
545    use crate::test_support::env::MapEnv;
546    use std::env;
547    use std::fs;
548    use tempfile::TempDir;
549
550    /// Builds a `Settings` with a base `env` and one profile, for the pure
551    /// resolver tests (no disk, no process env).
552    fn settings_with_profile() -> Settings {
553        let mut base = HashMap::new();
554        base.insert("ATLASSIAN_EMAIL".to_string(), "base@x.com".to_string());
555        base.insert("SHARED".to_string(), "base-shared".to_string());
556
557        let mut work_env = HashMap::new();
558        work_env.insert("ATLASSIAN_EMAIL".to_string(), "me@work.com".to_string());
559
560        let mut profiles = HashMap::new();
561        profiles.insert("work".to_string(), Profile { env: work_env });
562
563        Settings {
564            env: base,
565            profiles,
566        }
567    }
568
569    #[test]
570    fn settings_load_from_path() {
571        // Create a temporary directory (use current dir to avoid TMPDIR issues in tarpaulin)
572        let temp_dir = {
573            std::fs::create_dir_all("tmp").ok();
574            TempDir::new_in("tmp").unwrap()
575        };
576        let settings_path = temp_dir.path().join("settings.json");
577
578        // Create a test settings file
579        let settings_json = r#"{
580            "env": {
581                "TEST_VAR": "test_value",
582                "CLAUDE_API_KEY": "test_api_key"
583            }
584        }"#;
585        fs::write(&settings_path, settings_json).unwrap();
586
587        // Load settings
588        let settings = Settings::load_from_path(&settings_path).unwrap();
589
590        // Check env vars
591        assert_eq!(settings.env.get("TEST_VAR").unwrap(), "test_value");
592        assert_eq!(settings.env.get("CLAUDE_API_KEY").unwrap(), "test_api_key");
593    }
594
595    #[test]
596    fn settings_get_env_var() {
597        // Create a temporary directory (use current dir to avoid TMPDIR issues in tarpaulin)
598        let temp_dir = {
599            std::fs::create_dir_all("tmp").ok();
600            TempDir::new_in("tmp").unwrap()
601        };
602        let settings_path = temp_dir.path().join("settings.json");
603
604        // Create a test settings file
605        let settings_json = r#"{
606            "env": {
607                "TEST_VAR": "test_value",
608                "CLAUDE_API_KEY": "test_api_key"
609            }
610        }"#;
611        fs::write(&settings_path, settings_json).unwrap();
612
613        // Load settings
614        let settings = Settings::load_from_path(&settings_path).unwrap();
615
616        // Set actual environment variable
617        env::set_var("TEST_VAR_ENV", "env_value");
618
619        // Test precedence - env var should take precedence
620        env::set_var("TEST_VAR", "env_override");
621        assert_eq!(settings.get_env_var("TEST_VAR").unwrap(), "env_override");
622
623        // Test fallback to settings
624        env::remove_var("TEST_VAR"); // Remove from environment
625        assert_eq!(settings.get_env_var("TEST_VAR").unwrap(), "test_value");
626
627        // Test actual env var
628        assert_eq!(settings.get_env_var("TEST_VAR_ENV").unwrap(), "env_value");
629
630        // Clean up
631        env::remove_var("TEST_VAR_ENV");
632    }
633
634    // ── profile resolution (pure: MapEnv raw env, explicit active profile) ──
635
636    #[test]
637    fn resolve_no_profile_uses_base_env() {
638        let settings = settings_with_profile();
639        let raw = MapEnv::new();
640        assert_eq!(
641            settings
642                .resolve_with(&raw, None, "ATLASSIAN_EMAIL")
643                .as_deref(),
644            Some("base@x.com")
645        );
646    }
647
648    #[test]
649    fn resolve_active_profile_uses_profile_env() {
650        let settings = settings_with_profile();
651        let raw = MapEnv::new();
652        assert_eq!(
653            settings
654                .resolve_with(&raw, Some("work"), "ATLASSIAN_EMAIL")
655                .as_deref(),
656            Some("me@work.com")
657        );
658    }
659
660    #[test]
661    fn resolve_active_profile_does_not_consult_base() {
662        // Isolated / AWS-faithful: a key present only in base is invisible while
663        // a profile is active — fail loud rather than reuse a default token.
664        let settings = settings_with_profile();
665        let raw = MapEnv::new();
666        assert_eq!(settings.resolve_with(&raw, Some("work"), "SHARED"), None);
667    }
668
669    #[test]
670    fn resolve_process_env_wins_over_profile_and_base() {
671        let settings = settings_with_profile();
672        let raw = MapEnv::new().with("ATLASSIAN_EMAIL", "cli@x.com");
673        assert_eq!(
674            settings
675                .resolve_with(&raw, Some("work"), "ATLASSIAN_EMAIL")
676                .as_deref(),
677            Some("cli@x.com")
678        );
679        assert_eq!(
680            settings
681                .resolve_with(&raw, None, "ATLASSIAN_EMAIL")
682                .as_deref(),
683            Some("cli@x.com")
684        );
685    }
686
687    #[test]
688    fn resolve_unknown_active_profile_yields_none() {
689        // An unknown name never falls back to base; validation catches it at the
690        // CLI boundary, but the resolver itself stays isolated.
691        let settings = settings_with_profile();
692        let raw = MapEnv::new();
693        assert_eq!(
694            settings.resolve_with(&raw, Some("nope"), "ATLASSIAN_EMAIL"),
695            None
696        );
697    }
698
699    // ── sourced resolution (issue #1143: provenance for warnings) ──
700
701    #[test]
702    fn resolve_with_source_process_env_is_process_env() {
703        let settings = settings_with_profile();
704        let raw = MapEnv::new().with("ATLASSIAN_EMAIL", "cli@x.com");
705        assert_eq!(
706            settings.resolve_with_source(&raw, None, "ATLASSIAN_EMAIL"),
707            Some(("cli@x.com".to_string(), EnvValueSource::ProcessEnv))
708        );
709    }
710
711    #[test]
712    fn resolve_with_source_base_env_is_settings_env() {
713        let settings = settings_with_profile();
714        let raw = MapEnv::new();
715        assert_eq!(
716            settings.resolve_with_source(&raw, None, "ATLASSIAN_EMAIL"),
717            Some(("base@x.com".to_string(), EnvValueSource::SettingsEnv))
718        );
719    }
720
721    #[test]
722    fn resolve_with_source_profile_env_names_profile() {
723        let settings = settings_with_profile();
724        let raw = MapEnv::new();
725        assert_eq!(
726            settings.resolve_with_source(&raw, Some("work"), "ATLASSIAN_EMAIL"),
727            Some((
728                "me@work.com".to_string(),
729                EnvValueSource::SettingsProfile("work".to_string())
730            ))
731        );
732    }
733
734    #[test]
735    fn resolve_with_source_missing_key_is_none() {
736        let settings = settings_with_profile();
737        let raw = MapEnv::new();
738        assert_eq!(settings.resolve_with_source(&raw, None, "MISSING"), None);
739    }
740
741    #[test]
742    fn env_value_source_display_names_each_layer() {
743        assert_eq!(EnvValueSource::CliFlag.to_string(), "command-line flag");
744        assert_eq!(
745            EnvValueSource::ProcessEnv.to_string(),
746            "process environment variable (e.g. a shell export)"
747        );
748        assert_eq!(
749            EnvValueSource::SettingsEnv.to_string(),
750            "the env map in $HOME/.omni-dev/settings.json"
751        );
752        assert_eq!(
753            EnvValueSource::SettingsProfile("work".to_string()).to_string(),
754            "the profile 'work' env map in $HOME/.omni-dev/settings.json"
755        );
756    }
757
758    #[test]
759    fn active_profile_from_reads_and_trims_empty() {
760        assert_eq!(active_profile_from(&MapEnv::new()), None);
761        assert_eq!(
762            active_profile_from(&MapEnv::new().with(PROFILE_ENV_VAR, "")),
763            None
764        );
765        assert_eq!(
766            active_profile_from(&MapEnv::new().with(PROFILE_ENV_VAR, "work")).as_deref(),
767            Some("work")
768        );
769    }
770
771    #[test]
772    fn profile_suffix_names_profile_or_is_empty() {
773        assert_eq!(profile_suffix(None), "");
774        assert_eq!(profile_suffix(Some("work")), " (profile 'work')");
775    }
776
777    #[test]
778    fn validate_profile_accepts_known() {
779        assert!(settings_with_profile().validate_profile("work").is_ok());
780    }
781
782    #[test]
783    fn validate_profile_rejects_unknown_and_lists_sorted() {
784        let mut settings = settings_with_profile();
785        settings
786            .profiles
787            .insert("personal".to_string(), Profile::default());
788        let err = settings.validate_profile("wrok").unwrap_err().to_string();
789        assert_eq!(
790            err,
791            "unknown profile 'wrok'; known profiles: personal, work"
792        );
793    }
794
795    #[test]
796    fn validate_profile_reports_none_when_empty() {
797        let settings = Settings::default();
798        let err = settings.validate_profile("work").unwrap_err().to_string();
799        assert_eq!(err, "unknown profile 'work'; known profiles: (none)");
800    }
801
802    #[test]
803    fn settings_parse_profiles_from_json() {
804        let json = r#"{
805            "env": { "BASE": "b" },
806            "profiles": {
807                "work": { "env": { "ATLASSIAN_EMAIL": "me@work.com" } }
808            }
809        }"#;
810        let settings: Settings = serde_json::from_str(json).unwrap();
811        assert_eq!(settings.env.get("BASE").unwrap(), "b");
812        assert_eq!(
813            settings
814                .profiles
815                .get("work")
816                .unwrap()
817                .env
818                .get("ATLASSIAN_EMAIL")
819                .unwrap(),
820            "me@work.com"
821        );
822    }
823
824    #[test]
825    fn settings_without_profiles_key_defaults_empty() {
826        let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
827        assert!(settings.profiles.is_empty());
828    }
829
830    // ── free get_env_var seam (pure: injected raw env + lazy settings loader) ──
831
832    #[test]
833    fn get_env_var_with_returns_raw_hit_without_loading() {
834        let env = MapEnv::new().with("K", "v");
835        let value = get_env_var_with(&env, || panic!("must not load settings"), "K").unwrap();
836        assert_eq!(value, "v");
837    }
838
839    #[test]
840    fn get_env_var_with_falls_back_to_base_settings() {
841        let settings = settings_with_profile();
842        let env = MapEnv::new();
843        let value = get_env_var_with(&env, || Ok(settings), "ATLASSIAN_EMAIL").unwrap();
844        assert_eq!(value, "base@x.com");
845    }
846
847    #[test]
848    fn get_env_var_with_honours_active_profile() {
849        let settings = settings_with_profile();
850        let env = MapEnv::new().with(PROFILE_ENV_VAR, "work");
851        let value = get_env_var_with(&env, || Ok(settings), "ATLASSIAN_EMAIL").unwrap();
852        assert_eq!(value, "me@work.com");
853    }
854
855    #[test]
856    fn get_env_var_with_missing_key_is_not_found() {
857        let env = MapEnv::new();
858        let err = get_env_var_with(&env, || Ok(Settings::default()), "MISSING")
859            .unwrap_err()
860            .to_string();
861        assert!(err.contains("Environment variable not found: MISSING"));
862    }
863
864    #[test]
865    fn get_env_var_with_load_error_maps_to_not_found() {
866        let env = MapEnv::new();
867        let err =
868            get_env_var_with(&env, || Err(anyhow::anyhow!("disk boom")), "MISSING").unwrap_err();
869        // The load failure is the top-level context; the not-found error is its
870        // source. The full chain (`{:#}`) carries both.
871        assert_eq!(err.to_string(), "disk boom");
872        let chain = format!("{err:#}");
873        assert!(chain.contains("Environment variable not found: MISSING"));
874    }
875
876    // ── sourced get_env_var seam (issue #1143) ──
877
878    #[test]
879    fn get_env_var_sourced_with_raw_hit_is_process_env() {
880        let env = MapEnv::new().with("K", "v");
881        let resolved =
882            get_env_var_sourced_with(&env, || panic!("must not load settings"), false, "K")
883                .unwrap();
884        assert_eq!(resolved, ("v".to_string(), EnvValueSource::ProcessEnv));
885    }
886
887    #[test]
888    fn get_env_var_sourced_with_flag_export_is_cli_flag() {
889        let env = MapEnv::new().with("K", "true");
890        let resolved =
891            get_env_var_sourced_with(&env, || panic!("must not load settings"), true, "K").unwrap();
892        assert_eq!(resolved, ("true".to_string(), EnvValueSource::CliFlag));
893    }
894
895    #[test]
896    fn get_env_var_sourced_with_falls_back_to_settings_sources() {
897        let settings = settings_with_profile();
898        let env = MapEnv::new();
899        let resolved =
900            get_env_var_sourced_with(&env, || Ok(settings), false, "ATLASSIAN_EMAIL").unwrap();
901        assert_eq!(
902            resolved,
903            ("base@x.com".to_string(), EnvValueSource::SettingsEnv)
904        );
905
906        let settings = settings_with_profile();
907        let env = MapEnv::new().with(PROFILE_ENV_VAR, "work");
908        let resolved =
909            get_env_var_sourced_with(&env, || Ok(settings), false, "ATLASSIAN_EMAIL").unwrap();
910        assert_eq!(
911            resolved,
912            (
913                "me@work.com".to_string(),
914                EnvValueSource::SettingsProfile("work".to_string())
915            )
916        );
917    }
918
919    #[test]
920    fn cli_flag_export_registry_roundtrip() {
921        // Unique key: the registry is a process-global, additive-only set, so
922        // this test must not share keys with other tests (or production code).
923        const KEY: &str = "OMNI_DEV_TEST_1143_REGISTRY_ROUNDTRIP";
924        assert!(!exported_by_cli_flag(KEY));
925        note_cli_flag_export(KEY);
926        assert!(exported_by_cli_flag(KEY));
927    }
928
929    // ── env-write helpers (injected paths, no HOME mutation — issue #1030) ──
930
931    /// Creates a tempdir under `tmp/` (avoids TMPDIR issues in tarpaulin) and
932    /// returns it with a `<dir>/.omni-dev/settings.json` path inside it.
933    fn temp_settings_path() -> (TempDir, std::path::PathBuf) {
934        let temp_dir = {
935            std::fs::create_dir_all("tmp").ok();
936            TempDir::new_in("tmp").unwrap()
937        };
938        let path = temp_dir.path().join(".omni-dev").join("settings.json");
939        (temp_dir, path)
940    }
941
942    fn read_json(path: &Path) -> serde_json::Value {
943        serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap()
944    }
945
946    #[test]
947    fn upsert_env_vars_creates_file_and_dir_with_secure_permissions() {
948        let (_tmp, path) = temp_settings_path();
949
950        Settings::upsert_env_vars(&path, &[("A_KEY", "a"), ("B_KEY", "b")]).unwrap();
951
952        let val = read_json(&path);
953        assert_eq!(val["env"]["A_KEY"], "a");
954        assert_eq!(val["env"]["B_KEY"], "b");
955
956        // Credential store hardening (issue #1128): dir 0700, file 0600.
957        #[cfg(unix)]
958        {
959            use std::os::unix::fs::PermissionsExt;
960            let dir_mode = fs::metadata(path.parent().unwrap())
961                .unwrap()
962                .permissions()
963                .mode();
964            assert_eq!(dir_mode & 0o777, 0o700);
965            let file_mode = fs::metadata(&path).unwrap().permissions().mode();
966            assert_eq!(file_mode & 0o777, 0o600);
967        }
968    }
969
970    #[test]
971    fn upsert_env_vars_merges_and_preserves_unknown_fields() {
972        let (_tmp, path) = temp_settings_path();
973        fs::create_dir_all(path.parent().unwrap()).unwrap();
974        fs::write(&path, r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#).unwrap();
975
976        Settings::upsert_env_vars(&path, &[("A_KEY", "new")]).unwrap();
977
978        let val = read_json(&path);
979        assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
980        assert_eq!(val["extra"], true);
981        assert_eq!(val["env"]["A_KEY"], "new");
982    }
983
984    #[test]
985    fn upsert_env_vars_replaces_non_object_env() {
986        let (_tmp, path) = temp_settings_path();
987        fs::create_dir_all(path.parent().unwrap()).unwrap();
988        fs::write(&path, r#"{"env": "not-an-object"}"#).unwrap();
989
990        Settings::upsert_env_vars(&path, &[("A_KEY", "a")]).unwrap();
991
992        assert_eq!(read_json(&path)["env"]["A_KEY"], "a");
993    }
994
995    #[cfg(unix)]
996    #[test]
997    fn upsert_env_vars_retightens_loose_permissions() {
998        use std::os::unix::fs::PermissionsExt;
999
1000        let (_tmp, path) = temp_settings_path();
1001        fs::create_dir_all(path.parent().unwrap()).unwrap();
1002        fs::write(&path, r#"{"env": {}}"#).unwrap();
1003        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
1004
1005        Settings::upsert_env_vars(&path, &[("A_KEY", "a")]).unwrap();
1006
1007        let file_mode = fs::metadata(&path).unwrap().permissions().mode();
1008        assert_eq!(file_mode & 0o777, 0o600);
1009    }
1010
1011    #[test]
1012    fn remove_env_vars_removes_listed_keys_and_preserves_rest() {
1013        let (_tmp, path) = temp_settings_path();
1014        fs::create_dir_all(path.parent().unwrap()).unwrap();
1015        fs::write(
1016            &path,
1017            r#"{"env": {"A_KEY": "a", "B_KEY": "b", "OTHER_KEY": "keep"}, "extra": true}"#,
1018        )
1019        .unwrap();
1020
1021        let removed = Settings::remove_env_vars(&path, &["A_KEY", "B_KEY", "ABSENT"]).unwrap();
1022        assert!(removed);
1023
1024        let val = read_json(&path);
1025        assert!(val["env"].get("A_KEY").is_none());
1026        assert!(val["env"].get("B_KEY").is_none());
1027        assert_eq!(val["env"]["OTHER_KEY"], "keep");
1028        assert_eq!(val["extra"], true);
1029    }
1030
1031    #[test]
1032    fn remove_env_vars_false_when_file_missing() {
1033        let (_tmp, path) = temp_settings_path();
1034        assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
1035        assert!(!path.exists());
1036    }
1037
1038    #[test]
1039    fn remove_env_vars_false_when_env_missing_or_not_an_object() {
1040        let (_tmp, path) = temp_settings_path();
1041        fs::create_dir_all(path.parent().unwrap()).unwrap();
1042
1043        // No "env" key at all.
1044        fs::write(&path, r#"{"extra": true}"#).unwrap();
1045        assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
1046
1047        // "env" present but not an object.
1048        fs::write(&path, r#"{"env": "not-an-object"}"#).unwrap();
1049        assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
1050    }
1051
1052    #[test]
1053    fn upsert_env_vars_bare_filename_skips_dir_creation() {
1054        // A bare relative filename has an empty parent — the dir-creation
1055        // branch must be skipped, not fail on `create_dir_all("")`.
1056        let name = format!("tmp-upsert-bare-{}.json", std::process::id());
1057        let path = Path::new(&name);
1058
1059        Settings::upsert_env_vars(path, &[("A_KEY", "a")]).unwrap();
1060
1061        assert_eq!(read_json(path)["env"]["A_KEY"], "a");
1062        fs::remove_file(path).unwrap();
1063    }
1064
1065    #[test]
1066    fn remove_env_vars_false_when_keys_absent_leaves_file_untouched() {
1067        let (_tmp, path) = temp_settings_path();
1068        fs::create_dir_all(path.parent().unwrap()).unwrap();
1069        let original = r#"{"env": {"OTHER_KEY": "keep"}}"#;
1070        fs::write(&path, original).unwrap();
1071
1072        let removed = Settings::remove_env_vars(&path, &["A_KEY"]).unwrap();
1073        assert!(!removed);
1074        // Not rewritten: the raw bytes are exactly as written.
1075        assert_eq!(fs::read_to_string(&path).unwrap(), original);
1076    }
1077
1078    // ── profile-targeted env writes (issue #1116) ────────────────────
1079
1080    #[test]
1081    fn upsert_env_vars_in_profile_creates_profile_env() {
1082        let (_tmp, path) = temp_settings_path();
1083
1084        Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
1085
1086        let val = read_json(&path);
1087        assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "a");
1088        // The base env map is not touched (read-side isolation mirrored).
1089        assert!(val.get("env").is_none());
1090
1091        // Credential store hardening (issue #1128) applies to profile
1092        // writes too: dir 0700, file 0600.
1093        #[cfg(unix)]
1094        {
1095            use std::os::unix::fs::PermissionsExt;
1096            let dir_mode = fs::metadata(path.parent().unwrap())
1097                .unwrap()
1098                .permissions()
1099                .mode();
1100            assert_eq!(dir_mode & 0o777, 0o700);
1101            let file_mode = fs::metadata(&path).unwrap().permissions().mode();
1102            assert_eq!(file_mode & 0o777, 0o600);
1103        }
1104    }
1105
1106    #[test]
1107    fn upsert_env_vars_in_profile_preserves_base_and_other_profiles() {
1108        let (_tmp, path) = temp_settings_path();
1109        fs::create_dir_all(path.parent().unwrap()).unwrap();
1110        fs::write(
1111            &path,
1112            r#"{
1113                "env": {"SHARED": "base"},
1114                "profiles": {
1115                    "work": {"env": {"OLD": "keep"}},
1116                    "home": {"env": {"SHARED": "home"}}
1117                },
1118                "extra": true
1119            }"#,
1120        )
1121        .unwrap();
1122
1123        Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
1124
1125        let val = read_json(&path);
1126        assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "a");
1127        assert_eq!(val["profiles"]["work"]["env"]["OLD"], "keep");
1128        assert_eq!(val["profiles"]["home"]["env"]["SHARED"], "home");
1129        assert_eq!(val["env"]["SHARED"], "base");
1130        assert_eq!(val["extra"], true);
1131    }
1132
1133    #[test]
1134    fn upsert_env_vars_in_profile_replaces_non_object_nodes() {
1135        let (_tmp, path) = temp_settings_path();
1136        fs::create_dir_all(path.parent().unwrap()).unwrap();
1137
1138        // "profiles" itself is not an object.
1139        fs::write(&path, r#"{"profiles": "bogus"}"#).unwrap();
1140        Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
1141        assert_eq!(read_json(&path)["profiles"]["work"]["env"]["A_KEY"], "a");
1142
1143        // The profile node is not an object.
1144        fs::write(&path, r#"{"profiles": {"work": []}}"#).unwrap();
1145        Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
1146        assert_eq!(read_json(&path)["profiles"]["work"]["env"]["A_KEY"], "a");
1147    }
1148
1149    #[test]
1150    fn remove_env_vars_in_profile_removes_only_profile_keys() {
1151        let (_tmp, path) = temp_settings_path();
1152        fs::create_dir_all(path.parent().unwrap()).unwrap();
1153        fs::write(
1154            &path,
1155            r#"{
1156                "env": {"A_KEY": "base"},
1157                "profiles": {"work": {"env": {"A_KEY": "work", "OTHER": "keep"}}}
1158            }"#,
1159        )
1160        .unwrap();
1161
1162        let removed = Settings::remove_env_vars_in(&path, Some("work"), &["A_KEY"]).unwrap();
1163        assert!(removed);
1164
1165        let val = read_json(&path);
1166        assert!(val["profiles"]["work"]["env"].get("A_KEY").is_none());
1167        assert_eq!(val["profiles"]["work"]["env"]["OTHER"], "keep");
1168        // The base copy of the same key survives.
1169        assert_eq!(val["env"]["A_KEY"], "base");
1170    }
1171
1172    #[test]
1173    fn remove_env_vars_in_profile_false_when_profile_missing() {
1174        let (_tmp, path) = temp_settings_path();
1175        fs::create_dir_all(path.parent().unwrap()).unwrap();
1176        let original = r#"{"env": {"A_KEY": "base"}}"#;
1177        fs::write(&path, original).unwrap();
1178
1179        let removed = Settings::remove_env_vars_in(&path, Some("work"), &["A_KEY"]).unwrap();
1180        assert!(!removed);
1181        // Not rewritten: the raw bytes are exactly as written.
1182        assert_eq!(fs::read_to_string(&path).unwrap(), original);
1183    }
1184
1185    #[test]
1186    fn remove_env_vars_in_none_targets_base_env() {
1187        let (_tmp, path) = temp_settings_path();
1188        fs::create_dir_all(path.parent().unwrap()).unwrap();
1189        fs::write(
1190            &path,
1191            r#"{"env": {"A_KEY": "base"}, "profiles": {"work": {"env": {"A_KEY": "work"}}}}"#,
1192        )
1193        .unwrap();
1194
1195        let removed = Settings::remove_env_vars_in(&path, None, &["A_KEY"]).unwrap();
1196        assert!(removed);
1197
1198        let val = read_json(&path);
1199        assert!(val["env"].get("A_KEY").is_none());
1200        assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "work");
1201    }
1202}