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