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/// A single named Gmail account's stored OAuth2 credentials, inside the
134/// `gmail.accounts` map (issue #1500, [ADR-0066](../../docs/adrs/adr-0066.md)).
135///
136/// Orthogonal to [`Profile`]: selecting a Gmail account never changes the
137/// active `--profile`, and vice versa. See `email_address`'s own doc
138/// comment below for how it's populated and used — never for
139/// authentication itself.
140#[derive(Debug, Default, Deserialize)]
141pub struct GmailAccountSettings {
142    /// OAuth2 client id from the account's Google Cloud project.
143    #[serde(default)]
144    pub client_id: Option<String>,
145    /// OAuth2 client secret from the account's Google Cloud project.
146    #[serde(default)]
147    pub client_secret: Option<String>,
148    /// The long-lived refresh token obtained by `gmail auth login`.
149    #[serde(default)]
150    pub refresh_token: Option<String>,
151    /// The OAuth2 scope this account was authorized with.
152    #[serde(default)]
153    pub scope: Option<String>,
154    /// Mailbox address for this account. Populated opportunistically by
155    /// `gmail auth status` when absent, but may also be set by hand ahead of
156    /// the first login — e.g. to opt into `chrome_profile_from_email` below.
157    /// An explicit value is never overwritten by the `gmail auth status`
158    /// backfill. Never used for authentication itself, only for browser
159    /// targeting.
160    #[serde(default)]
161    pub email_address: Option<String>,
162
163    /// Opt-in (default `false`): resolve which local Chrome profile is
164    /// signed into `email_address` and launch `gmail auth login`'s
165    /// authorization URL targeting that profile, instead of the OS default
166    /// browser. Resolution failure (Chrome not installed, zero or multiple
167    /// matching profiles, ...) always falls back to the default browser —
168    /// never a hard login failure. Ignored when `browser_command` is set.
169    /// (issue #1505, [ADR-0067](../../docs/adrs/adr-0067.md))
170    #[serde(default)]
171    pub chrome_profile_from_email: bool,
172
173    /// Explicit browser launch command for `gmail auth login`, `{url}`
174    /// templated (or appended if no placeholder is present) — the manual
175    /// escape hatch, e.g. to target a specific Chrome profile by hand or a
176    /// non-Chrome browser entirely. Mirrors `SNOWFLAKE_BROWSER_COMMAND`
177    /// (`crate::snowflake`). Takes precedence over `chrome_profile_from_email`.
178    /// (issue #1505)
179    #[serde(default)]
180    pub browser_command: Option<String>,
181}
182
183/// The `gmail` section of `settings.json` — named Gmail accounts, selected
184/// per invocation via `--account` / `OMNI_DEV_GMAIL_ACCOUNT`
185/// (issue #1500, [ADR-0066](../../docs/adrs/adr-0066.md)).
186///
187/// An absent `gmail` block (or an empty `accounts` map) leaves Gmail
188/// credential resolution on today's exact legacy path — see
189/// `crate::gmail::account::resolve_account`.
190#[derive(Debug, Default, Deserialize)]
191pub struct GmailSettings {
192    /// The account `gmail account list`/credential resolution falls back to
193    /// when `--account`/`OMNI_DEV_GMAIL_ACCOUNT` is unset and more than one
194    /// account is configured.
195    #[serde(default)]
196    pub default_account: Option<String>,
197
198    /// Named accounts, keyed by the name passed to `--account`.
199    #[serde(default)]
200    pub accounts: HashMap<String, GmailAccountSettings>,
201}
202
203/// A single named Google Drive account's stored OAuth2 credentials, inside
204/// the `drive.accounts` map (issue #1520,
205/// [ADR-0069](../../docs/adrs/adr-0069.md)).
206///
207/// A field-for-field mirror of [`GmailAccountSettings`]: a Drive account and
208/// a Gmail account are different credential axes even when the underlying
209/// human is the same person, so the two blocks compose independently rather
210/// than one nesting under the other. See `email_address`'s own doc comment
211/// below for how it's populated and used — never for authentication itself.
212#[derive(Debug, Default, Deserialize)]
213pub struct DriveAccountSettings {
214    /// OAuth2 client id from the account's Google Cloud project.
215    #[serde(default)]
216    pub client_id: Option<String>,
217    /// OAuth2 client secret from the account's Google Cloud project.
218    #[serde(default)]
219    pub client_secret: Option<String>,
220    /// The long-lived refresh token obtained by `drive auth login`
221    /// (issue #1523).
222    #[serde(default)]
223    pub refresh_token: Option<String>,
224    /// The OAuth2 scope this account was authorized with, as the raw string
225    /// Google granted. Stays a plain `String` here (unlike Gmail's
226    /// equivalent settings field, for the same reason): this is a
227    /// status-reporting cache of whatever was actually granted, not the
228    /// value driving a live login decision — that's
229    /// [`crate::drive::auth::DriveScope`], read from this field via
230    /// [`crate::drive::auth::DriveScope::from_granted`]
231    /// ([ADR-0070](../../docs/adrs/adr-0070.md), reversing
232    /// [ADR-0069](../../docs/adrs/adr-0069.md) §2's "no `DriveScope` enum,
233    /// read-only by design").
234    #[serde(default)]
235    pub scope: Option<String>,
236    /// Google account address for this account. Populated opportunistically
237    /// by `drive auth status` when absent, but may also be set by hand ahead
238    /// of the first login — e.g. to opt into `chrome_profile_from_email`
239    /// below. An explicit value is never overwritten by the backfill. Never
240    /// used for authentication itself, only for browser targeting.
241    #[serde(default)]
242    pub email_address: Option<String>,
243
244    /// Opt-in (default `false`): resolve which local Chrome profile is
245    /// signed into `email_address` and launch `drive auth login`'s
246    /// authorization URL targeting that profile, instead of the OS default
247    /// browser. Resolution failure (Chrome not installed, zero or multiple
248    /// matching profiles, ...) always falls back to the default browser —
249    /// never a hard login failure. Ignored when `browser_command` is set.
250    /// (Inherited from Gmail by field —
251    /// [ADR-0067](../../docs/adrs/adr-0067.md) via
252    /// [ADR-0069](../../docs/adrs/adr-0069.md).)
253    #[serde(default)]
254    pub chrome_profile_from_email: bool,
255
256    /// Explicit browser launch command for `drive auth login`, `{url}`
257    /// templated (or appended if no placeholder is present) — the manual
258    /// escape hatch, e.g. to target a specific Chrome profile by hand or a
259    /// non-Chrome browser entirely. Mirrors `SNOWFLAKE_BROWSER_COMMAND`
260    /// (`crate::snowflake`). Takes precedence over
261    /// `chrome_profile_from_email`. (Inherited from Gmail by field —
262    /// [ADR-0067](../../docs/adrs/adr-0067.md).)
263    #[serde(default)]
264    pub browser_command: Option<String>,
265}
266
267/// The `drive` section of `settings.json` — named Google Drive accounts,
268/// selected per invocation via `--account` / `OMNI_DEV_DRIVE_ACCOUNT`
269/// (issue #1520, [ADR-0069](../../docs/adrs/adr-0069.md)).
270///
271/// An absent `drive` block (or an empty `accounts` map) means Drive is
272/// simply unconfigured — unlike [`GmailSettings`], there is no legacy
273/// credential path to fall back to, Drive being brand new.
274#[derive(Debug, Default, Deserialize)]
275pub struct DriveSettings {
276    /// The account credential resolution falls back to when
277    /// `--account`/`OMNI_DEV_DRIVE_ACCOUNT` is unset and more than one
278    /// account is configured.
279    #[serde(default)]
280    pub default_account: Option<String>,
281
282    /// Named accounts, keyed by the name passed to `--account`.
283    #[serde(default)]
284    pub accounts: HashMap<String, DriveAccountSettings>,
285}
286
287/// Settings loaded from $HOME/.omni-dev/settings.json.
288#[derive(Debug, Default, Deserialize)]
289pub struct Settings {
290    /// Environment variable overrides — the default bundle, consulted only when
291    /// **no** profile is active.
292    #[serde(default)]
293    pub env: HashMap<String, String>,
294
295    /// Named profiles. Selecting one replaces the base `env` in the fallback
296    /// chain (isolated / AWS-faithful); see [`Settings::resolve_with`].
297    #[serde(default)]
298    pub profiles: HashMap<String, Profile>,
299
300    /// MCP server defaults (issue #620); an absent block yields
301    /// [`McpSettings::default`].
302    #[serde(default)]
303    pub mcp: McpSettings,
304
305    /// Named Gmail accounts (issue #1500); an absent block yields
306    /// [`GmailSettings::default`], which is an empty account map.
307    #[serde(default)]
308    pub gmail: GmailSettings,
309
310    /// Named Google Drive accounts (issue #1520); an absent block yields
311    /// [`DriveSettings::default`], which is an empty account map.
312    #[serde(default)]
313    pub drive: DriveSettings,
314}
315
316/// Returns the active profile name from `raw` (the process environment), or
317/// `None` when `OMNI_DEV_PROFILE` is unset or empty.
318///
319/// Reads the **raw** env only, so it is pure over the injected source and never
320/// resolves through the profile fallback.
321pub fn active_profile_from<E: EnvSource>(raw: &E) -> Option<String> {
322    raw.var(PROFILE_ENV_VAR).filter(|s| !s.is_empty())
323}
324
325/// Renders ` (profile '<name>')` for credential-store CLI messages, or the
326/// empty string when no profile is active — so `auth login`/`logout` output
327/// names the env map it actually wrote to (issue #1116).
328#[must_use]
329pub fn profile_suffix(profile: Option<&str>) -> String {
330    profile.map_or_else(String::new, |name| format!(" (profile '{name}')"))
331}
332
333/// An [`EnvSource`](crate::utils::env::EnvSource) with the settings/profile
334/// fallback — the value form of [`get_env_var`].
335///
336/// Reads the real process environment first, then the active profile's `env`
337/// (or the base `env` when no profile is active) in
338/// `$HOME/.omni-dev/settings.json`.
339///
340/// Pass `&SettingsEnv::load()` from a thin production wrapper; tests inject a
341/// pure `MapEnv` into the same `*_with(&impl EnvSource, …)` seam instead of
342/// mutating the process environment.
343#[derive(Debug, Default)]
344pub struct SettingsEnv {
345    settings: Settings,
346    active_profile: Option<String>,
347}
348
349impl SettingsEnv {
350    /// Loads settings from the default location, falling back to an empty
351    /// settings map if they are absent or unreadable (env-only behaviour). The
352    /// active profile is read from `OMNI_DEV_PROFILE`.
353    pub fn load() -> Self {
354        Self::load_with_profile(active_profile_from(&SystemEnv).as_deref())
355    }
356
357    /// Like [`load`](Self::load) but with the active profile supplied
358    /// explicitly — for tests and embedders that select a profile without
359    /// setting `OMNI_DEV_PROFILE` in the process environment.
360    pub fn load_with_profile(profile: Option<&str>) -> Self {
361        Self {
362            settings: Settings::load().unwrap_or_default(),
363            active_profile: profile.map(str::to_string),
364        }
365    }
366
367    /// Wraps an already-loaded [`Settings`], skipping the disk read/parse
368    /// [`load`](Self::load)/[`load_with_profile`](Self::load_with_profile)
369    /// perform — for callers (e.g. `load_credentials_for`/`status_for` in
370    /// `crate::gmail::auth`/`crate::drive::auth`) that already loaded
371    /// `Settings` to resolve an account and would otherwise discard it just
372    /// to re-read the same file a second time (issue #1533).
373    pub fn from_settings(settings: Settings, profile: Option<&str>) -> Self {
374        Self {
375            settings,
376            active_profile: profile.map(str::to_string),
377        }
378    }
379}
380
381impl EnvSource for SettingsEnv {
382    fn var(&self, key: &str) -> Option<String> {
383        self.settings
384            .resolve_with(&SystemEnv, self.active_profile.as_deref(), key)
385    }
386}
387
388impl Settings {
389    /// Loads settings from the default location.
390    pub fn load() -> Result<Self> {
391        let settings_path = Self::get_settings_path()?;
392        Self::load_from_path(&settings_path)
393    }
394
395    /// Loads just the [`mcp`](McpSettings) section, falling back to its defaults
396    /// when the settings file is absent or unreadable — so the MCP server always
397    /// boots even with a malformed `settings.json` (issue #620). Mirrors the
398    /// graceful `unwrap_or_default` of [`SettingsEnv::load`].
399    pub fn load_mcp() -> McpSettings {
400        Self::load().map(|s| s.mcp).unwrap_or_default()
401    }
402
403    /// Loads settings from a specific path.
404    pub fn load_from_path<P: AsRef<Path>>(path: P) -> Result<Self> {
405        let path = path.as_ref();
406
407        // If file doesn't exist, return default settings
408        if !path.exists() {
409            return Ok(Self::default());
410        }
411
412        // Read and parse the settings file
413        let content = fs::read_to_string(path)
414            .with_context(|| format!("Failed to read settings file: {}", path.display()))?;
415
416        serde_json::from_str::<Self>(&content)
417            .with_context(|| format!("Failed to parse settings file: {}", path.display()))
418    }
419
420    /// Returns the default settings path.
421    pub fn get_settings_path() -> Result<PathBuf> {
422        let home_dir = dirs::home_dir().context("Failed to determine home directory")?;
423
424        Ok(home_dir.join(".omni-dev").join("settings.json"))
425    }
426
427    /// Returns an environment variable with fallback to settings, honouring the
428    /// active profile from `OMNI_DEV_PROFILE`.
429    pub fn get_env_var(&self, key: &str) -> Option<String> {
430        self.resolve_with(&SystemEnv, active_profile_from(&SystemEnv).as_deref(), key)
431    }
432
433    /// Isolated / AWS-faithful resolution: `raw` (the process environment) wins;
434    /// then the active profile's `env` if `active` is set, else the base `env`.
435    /// The base map is **not** consulted when a profile is active, so a missing
436    /// key fails loud rather than silently reusing a default credential against
437    /// the wrong tenant.
438    ///
439    /// This is the pure seam: production wrappers pass `&SystemEnv`; tests pass
440    /// a `MapEnv` and an explicit `active`, mutating no process-global state.
441    pub fn resolve_with<E: EnvSource>(
442        &self,
443        raw: &E,
444        active: Option<&str>,
445        key: &str,
446    ) -> Option<String> {
447        self.resolve_with_source(raw, active, key)
448            .map(|(value, _)| value)
449    }
450
451    /// Like [`Settings::resolve_with`], but also reports which layer supplied
452    /// the value: the raw process environment, the active profile's `env`, or
453    /// the base `env` (issue #1143). Same precedence, same profile isolation.
454    ///
455    /// A [`EnvValueSource::CliFlag`] attribution is layered on top by
456    /// [`get_env_var_sourced`], which knows about flag exports; this resolver
457    /// only distinguishes what it can see.
458    pub fn resolve_with_source<E: EnvSource>(
459        &self,
460        raw: &E,
461        active: Option<&str>,
462        key: &str,
463    ) -> Option<(String, EnvValueSource)> {
464        if let Some(value) = raw.var(key) {
465            return Some((value, EnvValueSource::ProcessEnv));
466        }
467        match active {
468            Some(name) => self
469                .profiles
470                .get(name)
471                .and_then(|p| p.env.get(key).cloned())
472                .map(|value| (value, EnvValueSource::SettingsProfile(name.to_string()))),
473            None => self
474                .env
475                .get(key)
476                .cloned()
477                .map(|value| (value, EnvValueSource::SettingsEnv)),
478        }
479    }
480
481    /// Merges the given key/value pairs into the base `env` object of the
482    /// settings file at `path` — [`Settings::upsert_env_vars_in`] with no
483    /// profile.
484    pub fn upsert_env_vars(path: &Path, vars: &[(&str, &str)]) -> Result<()> {
485        Self::upsert_env_vars_in(path, None, vars)
486    }
487
488    /// Merges the given key/value pairs into the `env` object targeted by
489    /// `profile` — `profiles.<name>.env` when `Some`, the base `env` when
490    /// `None` — creating the file, its parent directory, and any missing
491    /// intermediate objects as needed. Writes therefore land where
492    /// [`Settings::resolve_with`] will look for them (issue #1116).
493    ///
494    /// A `profile` absent from the file is created rather than rejected; the
495    /// CLI validates the active profile before dispatch, so this only affects
496    /// library callers.
497    ///
498    /// The file is read and written as a generic JSON value, so every other
499    /// field (other profiles, unknown keys) is preserved verbatim. Because the
500    /// `env` maps hold credentials, the write is hardened: parent directory
501    /// `0700`, file `0600` (see [`write_settings`]).
502    pub fn upsert_env_vars_in(
503        path: &Path,
504        profile: Option<&str>,
505        vars: &[(&str, &str)],
506    ) -> Result<()> {
507        let mut settings_value = read_or_default_settings(path)?;
508
509        let env = ensure_env_object(&mut settings_value, profile)?;
510        for (key, value) in vars {
511            env.insert(
512                (*key).to_string(),
513                serde_json::Value::String((*value).to_string()),
514            );
515        }
516
517        write_settings(path, &settings_value)
518    }
519
520    /// Removes the given keys from the base `env` object of the settings file
521    /// at `path` — [`Settings::remove_env_vars_in`] with no profile.
522    pub fn remove_env_vars(path: &Path, keys: &[&str]) -> Result<bool> {
523        Self::remove_env_vars_in(path, None, keys)
524    }
525
526    /// Removes the given keys from the `env` object targeted by `profile`
527    /// (`profiles.<name>.env` when `Some`, the base `env` when `None`),
528    /// leaving all other settings — including the same keys in other env
529    /// maps — intact.
530    ///
531    /// Returns `true` if any key was present in the targeted map and removed
532    /// (the file is rewritten, hardened as in
533    /// [`Settings::upsert_env_vars_in`]), `false` when the file did not
534    /// exist, the targeted map was absent, or it contained none of the keys
535    /// (the file is left untouched).
536    pub fn remove_env_vars_in(path: &Path, profile: Option<&str>, keys: &[&str]) -> Result<bool> {
537        if !path.exists() {
538            return Ok(false);
539        }
540        let mut settings_value = read_or_default_settings(path)?;
541
542        let mut removed = false;
543        if let Some(env) = env_object_mut(&mut settings_value, profile) {
544            for key in keys {
545                if env.remove(*key).is_some() {
546                    removed = true;
547                }
548            }
549        }
550
551        if removed {
552            write_settings(path, &settings_value)?;
553        }
554        Ok(removed)
555    }
556
557    /// Validates that `name` is a known profile, returning a hard error that
558    /// lists the known profiles (sorted) otherwise. Called once at the CLI
559    /// boundary so a typo never silently falls back to base credentials.
560    pub fn validate_profile(&self, name: &str) -> Result<()> {
561        if self.profiles.contains_key(name) {
562            return Ok(());
563        }
564        let known = if self.profiles.is_empty() {
565            "(none)".to_string()
566        } else {
567            let mut names: Vec<&str> = self.profiles.keys().map(String::as_str).collect();
568            names.sort_unstable();
569            names.join(", ")
570        };
571        Err(anyhow::anyhow!(
572            "unknown profile '{name}'; known profiles: {known}"
573        ))
574    }
575
576    /// Merges the given key/value pairs into `gmail.accounts.<account>`,
577    /// creating the file, its parent directory, and any missing intermediate
578    /// objects as needed (issue #1500,
579    /// [ADR-0066](../../docs/adrs/adr-0066.md)). Same hardening and
580    /// unknown-field preservation as [`Settings::upsert_env_vars_in`] — no
581    /// new file-handling code.
582    ///
583    /// `vars` takes [`serde_json::Value`] rather than `&str` (widened in
584    /// #1523, PR #1528 review) because `GmailAccountSettings` has non-string
585    /// fields (`chrome_profile_from_email: bool`) — a hard-coded
586    /// `Value::String` wrap would write the JSON string `"true"` into a
587    /// `bool` field, and since `Settings` deserializes as one unit, the next
588    /// `Settings::load()` would hard-fail parsing the *entire* file. Callers
589    /// writing a string field pass `serde_json::Value::String(...)`
590    /// explicitly.
591    pub fn upsert_gmail_account(
592        path: &Path,
593        account: &str,
594        vars: &[(&str, serde_json::Value)],
595    ) -> Result<()> {
596        let mut settings_value = read_or_default_settings(path)?;
597
598        let entry = ensure_object_at(&mut settings_value, &["gmail", "accounts", account])?;
599        for (key, value) in vars {
600            entry.insert((*key).to_string(), value.clone());
601        }
602
603        write_settings(path, &settings_value)
604    }
605
606    /// Removes `gmail.accounts.<account>` entirely — an account is coherent
607    /// as a unit, unlike the key-by-key removal
608    /// [`Settings::remove_env_vars_in`] does. Also clears
609    /// `gmail.default_account` if it named the removed account, so
610    /// resolution (`src/gmail/account.rs::resolve_default_account`) falls
611    /// back to the sole-remaining-account rule instead of hard-erroring on
612    /// a dangling pointer (issue #1529). Returns `true` if the account was
613    /// present and removed, `false` when the file, `gmail`,
614    /// `gmail.accounts`, or the named account did not exist (the file is
615    /// left untouched in that case).
616    pub fn remove_gmail_account(path: &Path, account: &str) -> Result<bool> {
617        if !path.exists() {
618            return Ok(false);
619        }
620        let mut settings_value = read_or_default_settings(path)?;
621
622        let removed = object_at_mut(&mut settings_value, &["gmail", "accounts"])
623            .is_some_and(|accounts| accounts.remove(account).is_some());
624
625        if removed {
626            if let Some(gmail) = object_at_mut(&mut settings_value, &["gmail"]) {
627                if gmail.get("default_account").and_then(|v| v.as_str()) == Some(account) {
628                    gmail.remove("default_account");
629                }
630            }
631            write_settings(path, &settings_value)?;
632        }
633        Ok(removed)
634    }
635
636    /// Sets (`Some`) or clears (`None`) `gmail.default_account`. Always
637    /// writes, mirroring [`Settings::upsert_env_vars_in`]'s unconditional-write
638    /// semantics rather than [`Settings::remove_env_vars_in`]'s
639    /// changed-only one, since this is fundamentally an upsert of a single
640    /// scalar rather than a set of keys.
641    pub fn set_gmail_default_account(path: &Path, account: Option<&str>) -> Result<()> {
642        let mut settings_value = read_or_default_settings(path)?;
643
644        match account {
645            Some(name) => {
646                let gmail = ensure_object_at(&mut settings_value, &["gmail"])?;
647                gmail.insert(
648                    "default_account".to_string(),
649                    serde_json::Value::String(name.to_string()),
650                );
651            }
652            None => {
653                if let Some(gmail) = object_at_mut(&mut settings_value, &["gmail"]) {
654                    gmail.remove("default_account");
655                }
656            }
657        }
658
659        write_settings(path, &settings_value)
660    }
661
662    /// Merges the given key/value pairs into `drive.accounts.<account>`,
663    /// creating the file, its parent directory, and any missing intermediate
664    /// objects as needed (issue #1522,
665    /// [ADR-0069](../../docs/adrs/adr-0069.md)). Same hardening and
666    /// unknown-field preservation as [`Settings::upsert_env_vars_in`] — no
667    /// new file-handling code.
668    ///
669    /// `vars` takes [`serde_json::Value`], not `&str` — see
670    /// [`Settings::upsert_gmail_account`]'s doc comment for why (this
671    /// helper's twin, and the write path the PR #1528 review comment
672    /// flagged the bug against).
673    pub fn upsert_drive_account(
674        path: &Path,
675        account: &str,
676        vars: &[(&str, serde_json::Value)],
677    ) -> Result<()> {
678        let mut settings_value = read_or_default_settings(path)?;
679
680        let entry = ensure_object_at(&mut settings_value, &["drive", "accounts", account])?;
681        for (key, value) in vars {
682            entry.insert((*key).to_string(), value.clone());
683        }
684
685        write_settings(path, &settings_value)
686    }
687
688    /// Removes `drive.accounts.<account>` entirely — an account is coherent
689    /// as a unit, exactly like [`Settings::remove_gmail_account`]. Also
690    /// clears `drive.default_account` if it named the removed account, for
691    /// the same dangling-pointer reason (issue #1529) — Drive has no
692    /// legacy-credential fallback to soften a stale default the way
693    /// Gmail's resolution table can (ADR-0069 §3), so the failure would be
694    /// total. Returns `true` if the account was present and removed,
695    /// `false` when the file, `drive`, `drive.accounts`, or the named
696    /// account did not exist (the file is left untouched in that case).
697    pub fn remove_drive_account(path: &Path, account: &str) -> Result<bool> {
698        if !path.exists() {
699            return Ok(false);
700        }
701        let mut settings_value = read_or_default_settings(path)?;
702
703        let removed = object_at_mut(&mut settings_value, &["drive", "accounts"])
704            .is_some_and(|accounts| accounts.remove(account).is_some());
705
706        if removed {
707            if let Some(drive) = object_at_mut(&mut settings_value, &["drive"]) {
708                if drive.get("default_account").and_then(|v| v.as_str()) == Some(account) {
709                    drive.remove("default_account");
710                }
711            }
712            write_settings(path, &settings_value)?;
713        }
714        Ok(removed)
715    }
716
717    /// Sets (`Some`) or clears (`None`) `drive.default_account`. Always
718    /// writes, mirroring [`Settings::set_gmail_default_account`]'s
719    /// unconditional-write semantics — fundamentally an upsert of a single
720    /// scalar rather than a set of keys.
721    pub fn set_drive_default_account(path: &Path, account: Option<&str>) -> Result<()> {
722        let mut settings_value = read_or_default_settings(path)?;
723
724        match account {
725            Some(name) => {
726                let drive = ensure_object_at(&mut settings_value, &["drive"])?;
727                drive.insert(
728                    "default_account".to_string(),
729                    serde_json::Value::String(name.to_string()),
730                );
731            }
732            None => {
733                if let Some(drive) = object_at_mut(&mut settings_value, &["drive"]) {
734                    drive.remove("default_account");
735                }
736            }
737        }
738
739        write_settings(path, &settings_value)
740    }
741}
742
743/// Navigates `root` to the env object targeted by `profile` — the base `env`
744/// when `None`, `profiles.<name>.env` when `Some`. Thin specialization of
745/// [`ensure_object_at`] for the two-level `env`/`profiles.<name>.env` shape.
746fn ensure_env_object<'a>(
747    root: &'a mut serde_json::Value,
748    profile: Option<&str>,
749) -> Result<&'a mut serde_json::Map<String, serde_json::Value>> {
750    match profile {
751        Some(name) => ensure_object_at(root, &["profiles", name, "env"]),
752        None => ensure_object_at(root, &["env"]),
753    }
754}
755
756/// Navigates `root` to the env object targeted by `profile`, or `None` when
757/// any node on the way is absent or not an object. Thin specialization of
758/// [`object_at_mut`] for the two-level `env`/`profiles.<name>.env` shape.
759fn env_object_mut<'a>(
760    root: &'a mut serde_json::Value,
761    profile: Option<&str>,
762) -> Option<&'a mut serde_json::Map<String, serde_json::Value>> {
763    match profile {
764        Some(name) => object_at_mut(root, &["profiles", name, "env"]),
765        None => object_at_mut(root, &["env"]),
766    }
767}
768
769/// Navigates `root` through each of `segments` in turn, creating missing
770/// intermediate objects and replacing non-object nodes along the way, and
771/// returns the object at the end of the path. The creating counterpart of
772/// [`object_at_mut`], for upserts. Generalizes what were previously two
773/// hardcoded two-level walks (`env`, `profiles.<name>.env`) to an arbitrary
774/// depth, so a third settings substructure (`gmail.accounts.<name>`, issue
775/// #1500) reuses the same file-handling code rather than duplicating it.
776fn ensure_object_at<'a>(
777    root: &'a mut serde_json::Value,
778    segments: &[&str],
779) -> Result<&'a mut serde_json::Map<String, serde_json::Value>> {
780    let mut current = root;
781    for segment in segments {
782        if !current
783            .get(*segment)
784            .is_some_and(serde_json::Value::is_object)
785        {
786            current[*segment] = serde_json::json!({});
787        }
788        current = current
789            .get_mut(*segment)
790            .context("Internal error: target key missing immediately after being created")?;
791    }
792    current
793        .as_object_mut()
794        .context("Internal error: target key is not an object after initialization")
795}
796
797/// Navigates `root` through each of `segments` in turn, or returns `None`
798/// when any node on the way is absent or not an object. The non-creating
799/// counterpart of [`ensure_object_at`], for removals.
800fn object_at_mut<'a>(
801    root: &'a mut serde_json::Value,
802    segments: &[&str],
803) -> Option<&'a mut serde_json::Map<String, serde_json::Value>> {
804    let mut current = root;
805    for segment in segments {
806        current = current.get_mut(*segment)?;
807    }
808    current.as_object_mut()
809}
810
811/// Reads and parses the settings file at `path` as a generic JSON value
812/// (preserving unknown fields), or returns `{}` when the file does not exist.
813fn read_or_default_settings(path: &Path) -> Result<serde_json::Value> {
814    if path.exists() {
815        let content = fs::read_to_string(path)
816            .with_context(|| format!("Failed to read {}", path.display()))?;
817        serde_json::from_str(&content)
818            .with_context(|| format!("Failed to parse {}", path.display()))
819    } else {
820        Ok(serde_json::json!({}))
821    }
822}
823
824/// The single hardened write site for the settings file: creates the parent
825/// directory `0700`, writes the pretty-printed JSON through a `0600` handle
826/// (no window where a fresh file is world-readable), and re-tightens a
827/// pre-existing looser-permission file on every write (issue #1128).
828fn write_settings(path: &Path, value: &serde_json::Value) -> Result<()> {
829    if let Some(parent) = path.parent() {
830        if !parent.as_os_str().is_empty() {
831            crate::daemon::paths::ensure_dir_0700(parent)?;
832        }
833    }
834    let formatted =
835        serde_json::to_string_pretty(value).context("Failed to serialize settings JSON")?;
836    write_file_0600(path, &formatted)
837        .with_context(|| format!("Failed to write {}", path.display()))?;
838    crate::daemon::paths::set_file_0600(path)?;
839    Ok(())
840}
841
842/// Creates/truncates `path` with owner-only (`0600`) permissions on Unix.
843#[cfg(unix)]
844fn write_file_0600(path: &Path, contents: &str) -> std::io::Result<()> {
845    use std::io::Write;
846    use std::os::unix::fs::OpenOptionsExt;
847
848    let mut file = fs::OpenOptions::new()
849        .write(true)
850        .create(true)
851        .truncate(true)
852        .mode(0o600)
853        .open(path)?;
854    file.write_all(contents.as_bytes())
855}
856
857/// Non-Unix fallback: a plain write ([`set_file_0600`](crate::daemon::paths::set_file_0600)
858/// is a no-op there too).
859#[cfg(not(unix))]
860fn write_file_0600(path: &Path, contents: &str) -> std::io::Result<()> {
861    fs::write(path, contents)
862}
863
864/// Returns an environment variable with fallback to settings, honouring the
865/// active profile from `OMNI_DEV_PROFILE`.
866pub fn get_env_var(key: &str) -> Result<String> {
867    get_env_var_with(&SystemEnv, Settings::load, key)
868}
869
870/// Like [`get_env_var`], but also reports where the value came from.
871///
872/// The source is a command-line flag export, the process environment, or a
873/// settings.json `env` map (issue #1143) — for warnings about
874/// security-sensitive values (e.g. the claude-cli escape hatches) that
875/// should name their source.
876pub fn get_env_var_sourced(key: &str) -> Result<(String, EnvValueSource)> {
877    get_env_var_sourced_with(&SystemEnv, Settings::load, exported_by_cli_flag(key), key)
878}
879
880/// Pure core of [`get_env_var`]: [`get_env_var_sourced_with`] with the source
881/// dropped.
882fn get_env_var_with<E, F>(env: &E, load: F, key: &str) -> Result<String>
883where
884    E: EnvSource,
885    F: FnOnce() -> Result<Settings>,
886{
887    get_env_var_sourced_with(env, load, false, key).map(|(value, _)| value)
888}
889
890/// Pure core of [`get_env_var_sourced`]: `env` is the raw source, `load`
891/// produces the settings lazily — it is invoked only on a raw-env miss,
892/// preserving the no-disk fast path — and `from_cli_flag` says whether a flag
893/// exported `key` this invocation (injected so tests never touch the
894/// process-global flag registry). Tests inject a `MapEnv` and a closure
895/// returning `Ok`/`Err` to cover both the resolved and load-failure branches
896/// without touching disk.
897fn get_env_var_sourced_with<E, F>(
898    env: &E,
899    load: F,
900    from_cli_flag: bool,
901    key: &str,
902) -> Result<(String, EnvValueSource)>
903where
904    E: EnvSource,
905    F: FnOnce() -> Result<Settings>,
906{
907    // A raw process-env hit short-circuits without loading settings from disk.
908    // A flag export always lands in the process env, so the flag attribution
909    // only ever applies on this branch.
910    if let Some(value) = env.var(key) {
911        let source = if from_cli_flag {
912            EnvValueSource::CliFlag
913        } else {
914            EnvValueSource::ProcessEnv
915        };
916        return Ok((value, source));
917    }
918    match load() {
919        Ok(settings) => settings
920            .resolve_with_source(env, active_profile_from(env).as_deref(), key)
921            .ok_or_else(|| anyhow::anyhow!("Environment variable not found: {key}")),
922        Err(err) => {
923            // If we couldn't load settings, just return the original env var error
924            Err(anyhow::anyhow!("Environment variable not found: {key}").context(err))
925        }
926    }
927}
928
929/// Tries multiple environment variables with fallback to settings.
930pub fn get_env_vars(keys: &[&str]) -> Result<String> {
931    for key in keys {
932        if let Ok(value) = get_env_var(key) {
933            return Ok(value);
934        }
935    }
936
937    Err(anyhow::anyhow!(
938        "None of the environment variables found: {keys:?}"
939    ))
940}
941
942#[cfg(test)]
943#[allow(clippy::unwrap_used, clippy::expect_used)]
944mod tests {
945    use super::*;
946    use crate::test_support::env::MapEnv;
947    use std::env;
948    use std::fs;
949    use tempfile::TempDir;
950
951    /// Builds a `Settings` with a base `env` and one profile, for the pure
952    /// resolver tests (no disk, no process env).
953    fn settings_with_profile() -> Settings {
954        let mut base = HashMap::new();
955        base.insert("ATLASSIAN_EMAIL".to_string(), "base@x.com".to_string());
956        base.insert("SHARED".to_string(), "base-shared".to_string());
957
958        let mut work_env = HashMap::new();
959        work_env.insert("ATLASSIAN_EMAIL".to_string(), "me@work.com".to_string());
960
961        let mut profiles = HashMap::new();
962        profiles.insert("work".to_string(), Profile { env: work_env });
963
964        Settings {
965            env: base,
966            profiles,
967            ..Settings::default()
968        }
969    }
970
971    #[test]
972    fn settings_load_from_path() {
973        // Create a temporary directory (use current dir to avoid TMPDIR issues in tarpaulin)
974        let temp_dir = {
975            std::fs::create_dir_all("tmp").ok();
976            TempDir::new_in("tmp").unwrap()
977        };
978        let settings_path = temp_dir.path().join("settings.json");
979
980        // Create a test settings file
981        let settings_json = r#"{
982            "env": {
983                "TEST_VAR": "test_value",
984                "CLAUDE_API_KEY": "test_api_key"
985            }
986        }"#;
987        fs::write(&settings_path, settings_json).unwrap();
988
989        // Load settings
990        let settings = Settings::load_from_path(&settings_path).unwrap();
991
992        // Check env vars
993        assert_eq!(settings.env.get("TEST_VAR").unwrap(), "test_value");
994        assert_eq!(settings.env.get("CLAUDE_API_KEY").unwrap(), "test_api_key");
995    }
996
997    #[test]
998    fn settings_get_env_var() {
999        // Create a temporary directory (use current dir to avoid TMPDIR issues in tarpaulin)
1000        let temp_dir = {
1001            std::fs::create_dir_all("tmp").ok();
1002            TempDir::new_in("tmp").unwrap()
1003        };
1004        let settings_path = temp_dir.path().join("settings.json");
1005
1006        // Create a test settings file
1007        let settings_json = r#"{
1008            "env": {
1009                "TEST_VAR": "test_value",
1010                "CLAUDE_API_KEY": "test_api_key"
1011            }
1012        }"#;
1013        fs::write(&settings_path, settings_json).unwrap();
1014
1015        // Load settings
1016        let settings = Settings::load_from_path(&settings_path).unwrap();
1017
1018        // Set actual environment variable
1019        env::set_var("TEST_VAR_ENV", "env_value");
1020
1021        // Test precedence - env var should take precedence
1022        env::set_var("TEST_VAR", "env_override");
1023        assert_eq!(settings.get_env_var("TEST_VAR").unwrap(), "env_override");
1024
1025        // Test fallback to settings
1026        env::remove_var("TEST_VAR"); // Remove from environment
1027        assert_eq!(settings.get_env_var("TEST_VAR").unwrap(), "test_value");
1028
1029        // Test actual env var
1030        assert_eq!(settings.get_env_var("TEST_VAR_ENV").unwrap(), "env_value");
1031
1032        // Clean up
1033        env::remove_var("TEST_VAR_ENV");
1034    }
1035
1036    // ── profile resolution (pure: MapEnv raw env, explicit active profile) ──
1037
1038    #[test]
1039    fn resolve_no_profile_uses_base_env() {
1040        let settings = settings_with_profile();
1041        let raw = MapEnv::new();
1042        assert_eq!(
1043            settings
1044                .resolve_with(&raw, None, "ATLASSIAN_EMAIL")
1045                .as_deref(),
1046            Some("base@x.com")
1047        );
1048    }
1049
1050    #[test]
1051    fn resolve_active_profile_uses_profile_env() {
1052        let settings = settings_with_profile();
1053        let raw = MapEnv::new();
1054        assert_eq!(
1055            settings
1056                .resolve_with(&raw, Some("work"), "ATLASSIAN_EMAIL")
1057                .as_deref(),
1058            Some("me@work.com")
1059        );
1060    }
1061
1062    #[test]
1063    fn resolve_active_profile_does_not_consult_base() {
1064        // Isolated / AWS-faithful: a key present only in base is invisible while
1065        // a profile is active — fail loud rather than reuse a default token.
1066        let settings = settings_with_profile();
1067        let raw = MapEnv::new();
1068        assert_eq!(settings.resolve_with(&raw, Some("work"), "SHARED"), None);
1069    }
1070
1071    #[test]
1072    fn resolve_process_env_wins_over_profile_and_base() {
1073        let settings = settings_with_profile();
1074        let raw = MapEnv::new().with("ATLASSIAN_EMAIL", "cli@x.com");
1075        assert_eq!(
1076            settings
1077                .resolve_with(&raw, Some("work"), "ATLASSIAN_EMAIL")
1078                .as_deref(),
1079            Some("cli@x.com")
1080        );
1081        assert_eq!(
1082            settings
1083                .resolve_with(&raw, None, "ATLASSIAN_EMAIL")
1084                .as_deref(),
1085            Some("cli@x.com")
1086        );
1087    }
1088
1089    #[test]
1090    fn resolve_unknown_active_profile_yields_none() {
1091        // An unknown name never falls back to base; validation catches it at the
1092        // CLI boundary, but the resolver itself stays isolated.
1093        let settings = settings_with_profile();
1094        let raw = MapEnv::new();
1095        assert_eq!(
1096            settings.resolve_with(&raw, Some("nope"), "ATLASSIAN_EMAIL"),
1097            None
1098        );
1099    }
1100
1101    // ── sourced resolution (issue #1143: provenance for warnings) ──
1102
1103    #[test]
1104    fn resolve_with_source_process_env_is_process_env() {
1105        let settings = settings_with_profile();
1106        let raw = MapEnv::new().with("ATLASSIAN_EMAIL", "cli@x.com");
1107        assert_eq!(
1108            settings.resolve_with_source(&raw, None, "ATLASSIAN_EMAIL"),
1109            Some(("cli@x.com".to_string(), EnvValueSource::ProcessEnv))
1110        );
1111    }
1112
1113    #[test]
1114    fn resolve_with_source_base_env_is_settings_env() {
1115        let settings = settings_with_profile();
1116        let raw = MapEnv::new();
1117        assert_eq!(
1118            settings.resolve_with_source(&raw, None, "ATLASSIAN_EMAIL"),
1119            Some(("base@x.com".to_string(), EnvValueSource::SettingsEnv))
1120        );
1121    }
1122
1123    #[test]
1124    fn resolve_with_source_profile_env_names_profile() {
1125        let settings = settings_with_profile();
1126        let raw = MapEnv::new();
1127        assert_eq!(
1128            settings.resolve_with_source(&raw, Some("work"), "ATLASSIAN_EMAIL"),
1129            Some((
1130                "me@work.com".to_string(),
1131                EnvValueSource::SettingsProfile("work".to_string())
1132            ))
1133        );
1134    }
1135
1136    #[test]
1137    fn resolve_with_source_missing_key_is_none() {
1138        let settings = settings_with_profile();
1139        let raw = MapEnv::new();
1140        assert_eq!(settings.resolve_with_source(&raw, None, "MISSING"), None);
1141    }
1142
1143    #[test]
1144    fn env_value_source_display_names_each_layer() {
1145        assert_eq!(EnvValueSource::CliFlag.to_string(), "command-line flag");
1146        assert_eq!(
1147            EnvValueSource::ProcessEnv.to_string(),
1148            "process environment variable (e.g. a shell export)"
1149        );
1150        assert_eq!(
1151            EnvValueSource::SettingsEnv.to_string(),
1152            "the env map in $HOME/.omni-dev/settings.json"
1153        );
1154        assert_eq!(
1155            EnvValueSource::SettingsProfile("work".to_string()).to_string(),
1156            "the profile 'work' env map in $HOME/.omni-dev/settings.json"
1157        );
1158    }
1159
1160    #[test]
1161    fn active_profile_from_reads_and_trims_empty() {
1162        assert_eq!(active_profile_from(&MapEnv::new()), None);
1163        assert_eq!(
1164            active_profile_from(&MapEnv::new().with(PROFILE_ENV_VAR, "")),
1165            None
1166        );
1167        assert_eq!(
1168            active_profile_from(&MapEnv::new().with(PROFILE_ENV_VAR, "work")).as_deref(),
1169            Some("work")
1170        );
1171    }
1172
1173    #[test]
1174    fn profile_suffix_names_profile_or_is_empty() {
1175        assert_eq!(profile_suffix(None), "");
1176        assert_eq!(profile_suffix(Some("work")), " (profile 'work')");
1177    }
1178
1179    #[test]
1180    fn validate_profile_accepts_known() {
1181        assert!(settings_with_profile().validate_profile("work").is_ok());
1182    }
1183
1184    #[test]
1185    fn validate_profile_rejects_unknown_and_lists_sorted() {
1186        let mut settings = settings_with_profile();
1187        settings
1188            .profiles
1189            .insert("personal".to_string(), Profile::default());
1190        let err = settings.validate_profile("wrok").unwrap_err().to_string();
1191        assert_eq!(
1192            err,
1193            "unknown profile 'wrok'; known profiles: personal, work"
1194        );
1195    }
1196
1197    #[test]
1198    fn validate_profile_reports_none_when_empty() {
1199        let settings = Settings::default();
1200        let err = settings.validate_profile("work").unwrap_err().to_string();
1201        assert_eq!(err, "unknown profile 'work'; known profiles: (none)");
1202    }
1203
1204    #[test]
1205    fn settings_parse_profiles_from_json() {
1206        let json = r#"{
1207            "env": { "BASE": "b" },
1208            "profiles": {
1209                "work": { "env": { "ATLASSIAN_EMAIL": "me@work.com" } }
1210            }
1211        }"#;
1212        let settings: Settings = serde_json::from_str(json).unwrap();
1213        assert_eq!(settings.env.get("BASE").unwrap(), "b");
1214        assert_eq!(
1215            settings
1216                .profiles
1217                .get("work")
1218                .unwrap()
1219                .env
1220                .get("ATLASSIAN_EMAIL")
1221                .unwrap(),
1222            "me@work.com"
1223        );
1224    }
1225
1226    #[test]
1227    fn settings_without_profiles_key_defaults_empty() {
1228        let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
1229        assert!(settings.profiles.is_empty());
1230    }
1231
1232    #[test]
1233    fn settings_parse_mcp_section_from_json() {
1234        let json = r#"{
1235            "mcp": {
1236                "default_model": "claude-sonnet-4-6",
1237                "log_level": "info",
1238                "max_response_bytes": 204800
1239            }
1240        }"#;
1241        let settings: Settings = serde_json::from_str(json).unwrap();
1242        assert_eq!(
1243            settings.mcp.default_model.as_deref(),
1244            Some("claude-sonnet-4-6")
1245        );
1246        assert_eq!(settings.mcp.log_level.as_deref(), Some("info"));
1247        assert_eq!(settings.mcp.max_response_bytes, Some(204_800));
1248    }
1249
1250    #[test]
1251    fn settings_without_mcp_key_defaults_all_none() {
1252        // An absent `mcp` block must leave every field unset so callers fall
1253        // back to the built-in defaults (byte-for-byte behaviour, issue #620).
1254        let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
1255        assert!(settings.mcp.default_model.is_none());
1256        assert!(settings.mcp.log_level.is_none());
1257        assert!(settings.mcp.max_response_bytes.is_none());
1258    }
1259
1260    #[test]
1261    fn settings_mcp_partial_section_leaves_others_none() {
1262        // A block that sets only one field leaves the rest at their defaults.
1263        let settings: Settings =
1264            serde_json::from_str(r#"{ "mcp": { "log_level": "debug" } }"#).unwrap();
1265        assert_eq!(settings.mcp.log_level.as_deref(), Some("debug"));
1266        assert!(settings.mcp.default_model.is_none());
1267        assert!(settings.mcp.max_response_bytes.is_none());
1268    }
1269
1270    #[test]
1271    fn settings_parse_gmail_section_from_json() {
1272        let json = r#"{
1273            "gmail": {
1274                "default_account": "work",
1275                "accounts": {
1276                    "work": {
1277                        "client_id": "id",
1278                        "client_secret": "secret",
1279                        "refresh_token": "token",
1280                        "scope": "https://www.googleapis.com/auth/gmail.modify",
1281                        "email_address": "alice@work.com"
1282                    }
1283                }
1284            }
1285        }"#;
1286        let settings: Settings = serde_json::from_str(json).unwrap();
1287        assert_eq!(settings.gmail.default_account.as_deref(), Some("work"));
1288        let account = settings.gmail.accounts.get("work").unwrap();
1289        assert_eq!(account.client_id.as_deref(), Some("id"));
1290        assert_eq!(account.client_secret.as_deref(), Some("secret"));
1291        assert_eq!(account.refresh_token.as_deref(), Some("token"));
1292        assert_eq!(
1293            account.scope.as_deref(),
1294            Some("https://www.googleapis.com/auth/gmail.modify")
1295        );
1296        assert_eq!(account.email_address.as_deref(), Some("alice@work.com"));
1297    }
1298
1299    #[test]
1300    fn settings_without_gmail_key_defaults_empty() {
1301        let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
1302        assert!(settings.gmail.default_account.is_none());
1303        assert!(settings.gmail.accounts.is_empty());
1304    }
1305
1306    #[test]
1307    fn settings_parse_drive_section_from_json() {
1308        let json = r#"{
1309            "drive": {
1310                "default_account": "work",
1311                "accounts": {
1312                    "work": {
1313                        "client_id": "id",
1314                        "client_secret": "secret",
1315                        "refresh_token": "token",
1316                        "scope": "https://www.googleapis.com/auth/drive.readonly",
1317                        "email_address": "alice@work.com"
1318                    }
1319                }
1320            }
1321        }"#;
1322        let settings: Settings = serde_json::from_str(json).unwrap();
1323        assert_eq!(settings.drive.default_account.as_deref(), Some("work"));
1324        let account = settings.drive.accounts.get("work").unwrap();
1325        assert_eq!(account.client_id.as_deref(), Some("id"));
1326        assert_eq!(account.client_secret.as_deref(), Some("secret"));
1327        assert_eq!(account.refresh_token.as_deref(), Some("token"));
1328        assert_eq!(
1329            account.scope.as_deref(),
1330            Some("https://www.googleapis.com/auth/drive.readonly")
1331        );
1332        assert_eq!(account.email_address.as_deref(), Some("alice@work.com"));
1333    }
1334
1335    #[test]
1336    fn settings_without_drive_key_defaults_empty() {
1337        let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
1338        assert!(settings.drive.default_account.is_none());
1339        assert!(settings.drive.accounts.is_empty());
1340    }
1341
1342    // ── free get_env_var seam (pure: injected raw env + lazy settings loader) ──
1343
1344    #[test]
1345    fn get_env_var_with_returns_raw_hit_without_loading() {
1346        let env = MapEnv::new().with("K", "v");
1347        let value = get_env_var_with(&env, || panic!("must not load settings"), "K").unwrap();
1348        assert_eq!(value, "v");
1349    }
1350
1351    #[test]
1352    fn get_env_var_with_falls_back_to_base_settings() {
1353        let settings = settings_with_profile();
1354        let env = MapEnv::new();
1355        let value = get_env_var_with(&env, || Ok(settings), "ATLASSIAN_EMAIL").unwrap();
1356        assert_eq!(value, "base@x.com");
1357    }
1358
1359    #[test]
1360    fn get_env_var_with_honours_active_profile() {
1361        let settings = settings_with_profile();
1362        let env = MapEnv::new().with(PROFILE_ENV_VAR, "work");
1363        let value = get_env_var_with(&env, || Ok(settings), "ATLASSIAN_EMAIL").unwrap();
1364        assert_eq!(value, "me@work.com");
1365    }
1366
1367    #[test]
1368    fn get_env_var_with_missing_key_is_not_found() {
1369        let env = MapEnv::new();
1370        let err = get_env_var_with(&env, || Ok(Settings::default()), "MISSING")
1371            .unwrap_err()
1372            .to_string();
1373        assert!(err.contains("Environment variable not found: MISSING"));
1374    }
1375
1376    #[test]
1377    fn get_env_var_with_load_error_maps_to_not_found() {
1378        let env = MapEnv::new();
1379        let err =
1380            get_env_var_with(&env, || Err(anyhow::anyhow!("disk boom")), "MISSING").unwrap_err();
1381        // The load failure is the top-level context; the not-found error is its
1382        // source. The full chain (`{:#}`) carries both.
1383        assert_eq!(err.to_string(), "disk boom");
1384        let chain = format!("{err:#}");
1385        assert!(chain.contains("Environment variable not found: MISSING"));
1386    }
1387
1388    // ── sourced get_env_var seam (issue #1143) ──
1389
1390    #[test]
1391    fn get_env_var_sourced_with_raw_hit_is_process_env() {
1392        let env = MapEnv::new().with("K", "v");
1393        let resolved =
1394            get_env_var_sourced_with(&env, || panic!("must not load settings"), false, "K")
1395                .unwrap();
1396        assert_eq!(resolved, ("v".to_string(), EnvValueSource::ProcessEnv));
1397    }
1398
1399    #[test]
1400    fn get_env_var_sourced_with_flag_export_is_cli_flag() {
1401        let env = MapEnv::new().with("K", "true");
1402        let resolved =
1403            get_env_var_sourced_with(&env, || panic!("must not load settings"), true, "K").unwrap();
1404        assert_eq!(resolved, ("true".to_string(), EnvValueSource::CliFlag));
1405    }
1406
1407    #[test]
1408    fn get_env_var_sourced_with_falls_back_to_settings_sources() {
1409        let settings = settings_with_profile();
1410        let env = MapEnv::new();
1411        let resolved =
1412            get_env_var_sourced_with(&env, || Ok(settings), false, "ATLASSIAN_EMAIL").unwrap();
1413        assert_eq!(
1414            resolved,
1415            ("base@x.com".to_string(), EnvValueSource::SettingsEnv)
1416        );
1417
1418        let settings = settings_with_profile();
1419        let env = MapEnv::new().with(PROFILE_ENV_VAR, "work");
1420        let resolved =
1421            get_env_var_sourced_with(&env, || Ok(settings), false, "ATLASSIAN_EMAIL").unwrap();
1422        assert_eq!(
1423            resolved,
1424            (
1425                "me@work.com".to_string(),
1426                EnvValueSource::SettingsProfile("work".to_string())
1427            )
1428        );
1429    }
1430
1431    #[test]
1432    fn cli_flag_export_registry_roundtrip() {
1433        // Unique key: the registry is a process-global, additive-only set, so
1434        // this test must not share keys with other tests (or production code).
1435        const KEY: &str = "OMNI_DEV_TEST_1143_REGISTRY_ROUNDTRIP";
1436        assert!(!exported_by_cli_flag(KEY));
1437        note_cli_flag_export(KEY);
1438        assert!(exported_by_cli_flag(KEY));
1439    }
1440
1441    // ── env-write helpers (injected paths, no HOME mutation — issue #1030) ──
1442
1443    /// Creates a tempdir under `tmp/` (avoids TMPDIR issues in tarpaulin) and
1444    /// returns it with a `<dir>/.omni-dev/settings.json` path inside it.
1445    fn temp_settings_path() -> (TempDir, std::path::PathBuf) {
1446        let temp_dir = {
1447            std::fs::create_dir_all("tmp").ok();
1448            TempDir::new_in("tmp").unwrap()
1449        };
1450        let path = temp_dir.path().join(".omni-dev").join("settings.json");
1451        (temp_dir, path)
1452    }
1453
1454    fn read_json(path: &Path) -> serde_json::Value {
1455        serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap()
1456    }
1457
1458    #[test]
1459    fn upsert_env_vars_creates_file_and_dir_with_secure_permissions() {
1460        let (_tmp, path) = temp_settings_path();
1461
1462        Settings::upsert_env_vars(&path, &[("A_KEY", "a"), ("B_KEY", "b")]).unwrap();
1463
1464        let val = read_json(&path);
1465        assert_eq!(val["env"]["A_KEY"], "a");
1466        assert_eq!(val["env"]["B_KEY"], "b");
1467
1468        // Credential store hardening (issue #1128): dir 0700, file 0600.
1469        #[cfg(unix)]
1470        {
1471            use std::os::unix::fs::PermissionsExt;
1472            let dir_mode = fs::metadata(path.parent().unwrap())
1473                .unwrap()
1474                .permissions()
1475                .mode();
1476            assert_eq!(dir_mode & 0o777, 0o700);
1477            let file_mode = fs::metadata(&path).unwrap().permissions().mode();
1478            assert_eq!(file_mode & 0o777, 0o600);
1479        }
1480    }
1481
1482    #[test]
1483    fn upsert_env_vars_merges_and_preserves_unknown_fields() {
1484        let (_tmp, path) = temp_settings_path();
1485        fs::create_dir_all(path.parent().unwrap()).unwrap();
1486        fs::write(&path, r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#).unwrap();
1487
1488        Settings::upsert_env_vars(&path, &[("A_KEY", "new")]).unwrap();
1489
1490        let val = read_json(&path);
1491        assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
1492        assert_eq!(val["extra"], true);
1493        assert_eq!(val["env"]["A_KEY"], "new");
1494    }
1495
1496    #[test]
1497    fn upsert_env_vars_replaces_non_object_env() {
1498        let (_tmp, path) = temp_settings_path();
1499        fs::create_dir_all(path.parent().unwrap()).unwrap();
1500        fs::write(&path, r#"{"env": "not-an-object"}"#).unwrap();
1501
1502        Settings::upsert_env_vars(&path, &[("A_KEY", "a")]).unwrap();
1503
1504        assert_eq!(read_json(&path)["env"]["A_KEY"], "a");
1505    }
1506
1507    #[cfg(unix)]
1508    #[test]
1509    fn upsert_env_vars_retightens_loose_permissions() {
1510        use std::os::unix::fs::PermissionsExt;
1511
1512        let (_tmp, path) = temp_settings_path();
1513        fs::create_dir_all(path.parent().unwrap()).unwrap();
1514        fs::write(&path, r#"{"env": {}}"#).unwrap();
1515        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
1516
1517        Settings::upsert_env_vars(&path, &[("A_KEY", "a")]).unwrap();
1518
1519        let file_mode = fs::metadata(&path).unwrap().permissions().mode();
1520        assert_eq!(file_mode & 0o777, 0o600);
1521    }
1522
1523    #[test]
1524    fn remove_env_vars_removes_listed_keys_and_preserves_rest() {
1525        let (_tmp, path) = temp_settings_path();
1526        fs::create_dir_all(path.parent().unwrap()).unwrap();
1527        fs::write(
1528            &path,
1529            r#"{"env": {"A_KEY": "a", "B_KEY": "b", "OTHER_KEY": "keep"}, "extra": true}"#,
1530        )
1531        .unwrap();
1532
1533        let removed = Settings::remove_env_vars(&path, &["A_KEY", "B_KEY", "ABSENT"]).unwrap();
1534        assert!(removed);
1535
1536        let val = read_json(&path);
1537        assert!(val["env"].get("A_KEY").is_none());
1538        assert!(val["env"].get("B_KEY").is_none());
1539        assert_eq!(val["env"]["OTHER_KEY"], "keep");
1540        assert_eq!(val["extra"], true);
1541    }
1542
1543    #[test]
1544    fn remove_env_vars_false_when_file_missing() {
1545        let (_tmp, path) = temp_settings_path();
1546        assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
1547        assert!(!path.exists());
1548    }
1549
1550    #[test]
1551    fn remove_env_vars_false_when_env_missing_or_not_an_object() {
1552        let (_tmp, path) = temp_settings_path();
1553        fs::create_dir_all(path.parent().unwrap()).unwrap();
1554
1555        // No "env" key at all.
1556        fs::write(&path, r#"{"extra": true}"#).unwrap();
1557        assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
1558
1559        // "env" present but not an object.
1560        fs::write(&path, r#"{"env": "not-an-object"}"#).unwrap();
1561        assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
1562    }
1563
1564    #[test]
1565    fn upsert_env_vars_bare_filename_skips_dir_creation() {
1566        // A bare relative filename has an empty parent — the dir-creation
1567        // branch must be skipped, not fail on `create_dir_all("")`.
1568        let name = format!("tmp-upsert-bare-{}.json", std::process::id());
1569        let path = Path::new(&name);
1570
1571        Settings::upsert_env_vars(path, &[("A_KEY", "a")]).unwrap();
1572
1573        assert_eq!(read_json(path)["env"]["A_KEY"], "a");
1574        fs::remove_file(path).unwrap();
1575    }
1576
1577    #[test]
1578    fn remove_env_vars_false_when_keys_absent_leaves_file_untouched() {
1579        let (_tmp, path) = temp_settings_path();
1580        fs::create_dir_all(path.parent().unwrap()).unwrap();
1581        let original = r#"{"env": {"OTHER_KEY": "keep"}}"#;
1582        fs::write(&path, original).unwrap();
1583
1584        let removed = Settings::remove_env_vars(&path, &["A_KEY"]).unwrap();
1585        assert!(!removed);
1586        // Not rewritten: the raw bytes are exactly as written.
1587        assert_eq!(fs::read_to_string(&path).unwrap(), original);
1588    }
1589
1590    // ── profile-targeted env writes (issue #1116) ────────────────────
1591
1592    #[test]
1593    fn upsert_env_vars_in_profile_creates_profile_env() {
1594        let (_tmp, path) = temp_settings_path();
1595
1596        Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
1597
1598        let val = read_json(&path);
1599        assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "a");
1600        // The base env map is not touched (read-side isolation mirrored).
1601        assert!(val.get("env").is_none());
1602
1603        // Credential store hardening (issue #1128) applies to profile
1604        // writes too: dir 0700, file 0600.
1605        #[cfg(unix)]
1606        {
1607            use std::os::unix::fs::PermissionsExt;
1608            let dir_mode = fs::metadata(path.parent().unwrap())
1609                .unwrap()
1610                .permissions()
1611                .mode();
1612            assert_eq!(dir_mode & 0o777, 0o700);
1613            let file_mode = fs::metadata(&path).unwrap().permissions().mode();
1614            assert_eq!(file_mode & 0o777, 0o600);
1615        }
1616    }
1617
1618    #[test]
1619    fn upsert_env_vars_in_profile_preserves_base_and_other_profiles() {
1620        let (_tmp, path) = temp_settings_path();
1621        fs::create_dir_all(path.parent().unwrap()).unwrap();
1622        fs::write(
1623            &path,
1624            r#"{
1625                "env": {"SHARED": "base"},
1626                "profiles": {
1627                    "work": {"env": {"OLD": "keep"}},
1628                    "home": {"env": {"SHARED": "home"}}
1629                },
1630                "extra": true
1631            }"#,
1632        )
1633        .unwrap();
1634
1635        Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
1636
1637        let val = read_json(&path);
1638        assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "a");
1639        assert_eq!(val["profiles"]["work"]["env"]["OLD"], "keep");
1640        assert_eq!(val["profiles"]["home"]["env"]["SHARED"], "home");
1641        assert_eq!(val["env"]["SHARED"], "base");
1642        assert_eq!(val["extra"], true);
1643    }
1644
1645    #[test]
1646    fn upsert_env_vars_in_profile_replaces_non_object_nodes() {
1647        let (_tmp, path) = temp_settings_path();
1648        fs::create_dir_all(path.parent().unwrap()).unwrap();
1649
1650        // "profiles" itself is not an object.
1651        fs::write(&path, r#"{"profiles": "bogus"}"#).unwrap();
1652        Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
1653        assert_eq!(read_json(&path)["profiles"]["work"]["env"]["A_KEY"], "a");
1654
1655        // The profile node is not an object.
1656        fs::write(&path, r#"{"profiles": {"work": []}}"#).unwrap();
1657        Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
1658        assert_eq!(read_json(&path)["profiles"]["work"]["env"]["A_KEY"], "a");
1659    }
1660
1661    #[test]
1662    fn remove_env_vars_in_profile_removes_only_profile_keys() {
1663        let (_tmp, path) = temp_settings_path();
1664        fs::create_dir_all(path.parent().unwrap()).unwrap();
1665        fs::write(
1666            &path,
1667            r#"{
1668                "env": {"A_KEY": "base"},
1669                "profiles": {"work": {"env": {"A_KEY": "work", "OTHER": "keep"}}}
1670            }"#,
1671        )
1672        .unwrap();
1673
1674        let removed = Settings::remove_env_vars_in(&path, Some("work"), &["A_KEY"]).unwrap();
1675        assert!(removed);
1676
1677        let val = read_json(&path);
1678        assert!(val["profiles"]["work"]["env"].get("A_KEY").is_none());
1679        assert_eq!(val["profiles"]["work"]["env"]["OTHER"], "keep");
1680        // The base copy of the same key survives.
1681        assert_eq!(val["env"]["A_KEY"], "base");
1682    }
1683
1684    #[test]
1685    fn remove_env_vars_in_profile_false_when_profile_missing() {
1686        let (_tmp, path) = temp_settings_path();
1687        fs::create_dir_all(path.parent().unwrap()).unwrap();
1688        let original = r#"{"env": {"A_KEY": "base"}}"#;
1689        fs::write(&path, original).unwrap();
1690
1691        let removed = Settings::remove_env_vars_in(&path, Some("work"), &["A_KEY"]).unwrap();
1692        assert!(!removed);
1693        // Not rewritten: the raw bytes are exactly as written.
1694        assert_eq!(fs::read_to_string(&path).unwrap(), original);
1695    }
1696
1697    #[test]
1698    fn remove_env_vars_in_none_targets_base_env() {
1699        let (_tmp, path) = temp_settings_path();
1700        fs::create_dir_all(path.parent().unwrap()).unwrap();
1701        fs::write(
1702            &path,
1703            r#"{"env": {"A_KEY": "base"}, "profiles": {"work": {"env": {"A_KEY": "work"}}}}"#,
1704        )
1705        .unwrap();
1706
1707        let removed = Settings::remove_env_vars_in(&path, None, &["A_KEY"]).unwrap();
1708        assert!(removed);
1709
1710        let val = read_json(&path);
1711        assert!(val["env"].get("A_KEY").is_none());
1712        assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "work");
1713    }
1714
1715    // ── generalized path-segment walkers (issue #1500) ────────────────
1716
1717    #[test]
1718    fn ensure_object_at_creates_nested_path_at_arbitrary_depth() {
1719        let mut root = serde_json::json!({});
1720        {
1721            let map = ensure_object_at(&mut root, &["gmail", "accounts", "work"]).unwrap();
1722            map.insert("client_id".to_string(), serde_json::json!("id"));
1723        }
1724        assert_eq!(root["gmail"]["accounts"]["work"]["client_id"], "id");
1725    }
1726
1727    #[test]
1728    fn ensure_object_at_replaces_non_object_nodes_along_path() {
1729        let mut root = serde_json::json!({"gmail": "bogus"});
1730        {
1731            let map = ensure_object_at(&mut root, &["gmail", "accounts", "work"]).unwrap();
1732            map.insert("client_id".to_string(), serde_json::json!("id"));
1733        }
1734        assert_eq!(root["gmail"]["accounts"]["work"]["client_id"], "id");
1735    }
1736
1737    #[test]
1738    fn object_at_mut_none_when_any_segment_absent() {
1739        let mut root = serde_json::json!({"gmail": {"accounts": {}}});
1740        assert!(object_at_mut(&mut root, &["gmail", "accounts", "work"]).is_none());
1741        assert!(object_at_mut(&mut root, &["missing", "accounts"]).is_none());
1742    }
1743
1744    // ── gmail account writes (issue #1500) ─────────────────────────────
1745
1746    #[test]
1747    fn upsert_gmail_account_creates_nested_path_and_preserves_siblings() {
1748        let (_tmp, path) = temp_settings_path();
1749        fs::create_dir_all(path.parent().unwrap()).unwrap();
1750        fs::write(
1751            &path,
1752            r#"{"env": {"SHARED": "base"}, "gmail": {"accounts": {"personal": {"client_id": "keep"}}}, "extra": true}"#,
1753        )
1754        .unwrap();
1755
1756        Settings::upsert_gmail_account(
1757            &path,
1758            "work",
1759            &[
1760                ("client_id", serde_json::Value::String("id".to_string())),
1761                (
1762                    "refresh_token",
1763                    serde_json::Value::String("token".to_string()),
1764                ),
1765            ],
1766        )
1767        .unwrap();
1768
1769        let val = read_json(&path);
1770        assert_eq!(val["gmail"]["accounts"]["work"]["client_id"], "id");
1771        assert_eq!(val["gmail"]["accounts"]["work"]["refresh_token"], "token");
1772        assert_eq!(val["gmail"]["accounts"]["personal"]["client_id"], "keep");
1773        assert_eq!(val["env"]["SHARED"], "base");
1774        assert_eq!(val["extra"], true);
1775
1776        #[cfg(unix)]
1777        {
1778            use std::os::unix::fs::PermissionsExt;
1779            let file_mode = fs::metadata(&path).unwrap().permissions().mode();
1780            assert_eq!(file_mode & 0o777, 0o600);
1781        }
1782    }
1783
1784    /// Regression test for the PR #1528 review comment: writing a non-string
1785    /// field (e.g. `chrome_profile_from_email: bool`) through
1786    /// `upsert_gmail_account` must round-trip through `Settings::load()` —
1787    /// before the `vars` type was widened to `serde_json::Value`, this
1788    /// silently wrote the JSON string `"true"` into a `bool` field and broke
1789    /// parsing of the entire settings file on next load.
1790    #[test]
1791    fn upsert_gmail_account_writes_a_bool_value_that_round_trips_through_settings_load() {
1792        let (_tmp, path) = temp_settings_path();
1793
1794        Settings::upsert_gmail_account(
1795            &path,
1796            "work",
1797            &[("chrome_profile_from_email", serde_json::Value::Bool(true))],
1798        )
1799        .unwrap();
1800
1801        let val = read_json(&path);
1802        assert_eq!(
1803            val["gmail"]["accounts"]["work"]["chrome_profile_from_email"],
1804            true
1805        );
1806
1807        let settings = Settings::load_from_path(&path).unwrap();
1808        assert!(
1809            settings.gmail.accounts["work"].chrome_profile_from_email,
1810            "the bool field must deserialize back to `true`, not the string \"true\""
1811        );
1812    }
1813
1814    #[test]
1815    fn remove_gmail_account_true_when_present_false_when_absent() {
1816        let (_tmp, path) = temp_settings_path();
1817        fs::create_dir_all(path.parent().unwrap()).unwrap();
1818        fs::write(
1819            &path,
1820            r#"{"gmail": {"accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
1821        )
1822        .unwrap();
1823
1824        assert!(Settings::remove_gmail_account(&path, "work").unwrap());
1825        let val = read_json(&path);
1826        assert!(val["gmail"]["accounts"].get("work").is_none());
1827        assert_eq!(val["gmail"]["accounts"]["personal"]["client_id"], "keep");
1828
1829        assert!(!Settings::remove_gmail_account(&path, "work").unwrap());
1830    }
1831
1832    #[test]
1833    fn remove_gmail_account_clears_default_account_when_it_named_the_removed_account() {
1834        let (_tmp, path) = temp_settings_path();
1835        fs::create_dir_all(path.parent().unwrap()).unwrap();
1836        fs::write(
1837            &path,
1838            r#"{"gmail": {"default_account": "work", "accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
1839        )
1840        .unwrap();
1841
1842        assert!(Settings::remove_gmail_account(&path, "work").unwrap());
1843        let val = read_json(&path);
1844        assert!(val["gmail"].get("default_account").is_none());
1845        assert_eq!(val["gmail"]["accounts"]["personal"]["client_id"], "keep");
1846    }
1847
1848    #[test]
1849    fn remove_gmail_account_leaves_default_account_untouched_when_it_names_a_different_account() {
1850        let (_tmp, path) = temp_settings_path();
1851        fs::create_dir_all(path.parent().unwrap()).unwrap();
1852        fs::write(
1853            &path,
1854            r#"{"gmail": {"default_account": "personal", "accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
1855        )
1856        .unwrap();
1857
1858        assert!(Settings::remove_gmail_account(&path, "work").unwrap());
1859        let val = read_json(&path);
1860        assert_eq!(val["gmail"]["default_account"], "personal");
1861    }
1862
1863    #[test]
1864    fn remove_gmail_account_false_when_file_missing() {
1865        let (_tmp, path) = temp_settings_path();
1866        assert!(!Settings::remove_gmail_account(&path, "work").unwrap());
1867        assert!(!path.exists());
1868    }
1869
1870    #[test]
1871    fn set_gmail_default_account_sets_and_clears() {
1872        let (_tmp, path) = temp_settings_path();
1873
1874        Settings::set_gmail_default_account(&path, Some("work")).unwrap();
1875        assert_eq!(read_json(&path)["gmail"]["default_account"], "work");
1876
1877        Settings::set_gmail_default_account(&path, None).unwrap();
1878        assert!(read_json(&path)["gmail"].get("default_account").is_none());
1879    }
1880
1881    // ── drive account writes (issue #1522) ─────────────────────────────
1882
1883    #[test]
1884    fn upsert_drive_account_creates_nested_path_and_preserves_siblings() {
1885        let (_tmp, path) = temp_settings_path();
1886        fs::create_dir_all(path.parent().unwrap()).unwrap();
1887        fs::write(
1888            &path,
1889            r#"{"env": {"SHARED": "base"}, "drive": {"accounts": {"personal": {"client_id": "keep"}}}, "extra": true}"#,
1890        )
1891        .unwrap();
1892
1893        Settings::upsert_drive_account(
1894            &path,
1895            "work",
1896            &[
1897                ("client_id", serde_json::Value::String("id".to_string())),
1898                (
1899                    "refresh_token",
1900                    serde_json::Value::String("token".to_string()),
1901                ),
1902            ],
1903        )
1904        .unwrap();
1905
1906        let val = read_json(&path);
1907        assert_eq!(val["drive"]["accounts"]["work"]["client_id"], "id");
1908        assert_eq!(val["drive"]["accounts"]["work"]["refresh_token"], "token");
1909        assert_eq!(val["drive"]["accounts"]["personal"]["client_id"], "keep");
1910        assert_eq!(val["env"]["SHARED"], "base");
1911        assert_eq!(val["extra"], true);
1912
1913        #[cfg(unix)]
1914        {
1915            use std::os::unix::fs::PermissionsExt;
1916            let file_mode = fs::metadata(&path).unwrap().permissions().mode();
1917            assert_eq!(file_mode & 0o777, 0o600);
1918        }
1919    }
1920
1921    /// Drive's twin of
1922    /// `upsert_gmail_account_writes_a_bool_value_that_round_trips_through_settings_load`
1923    /// — see that test's doc comment for the PR #1528 review comment this
1924    /// closes.
1925    #[test]
1926    fn upsert_drive_account_writes_a_bool_value_that_round_trips_through_settings_load() {
1927        let (_tmp, path) = temp_settings_path();
1928
1929        Settings::upsert_drive_account(
1930            &path,
1931            "work",
1932            &[("chrome_profile_from_email", serde_json::Value::Bool(true))],
1933        )
1934        .unwrap();
1935
1936        let val = read_json(&path);
1937        assert_eq!(
1938            val["drive"]["accounts"]["work"]["chrome_profile_from_email"],
1939            true
1940        );
1941
1942        let settings = Settings::load_from_path(&path).unwrap();
1943        assert!(
1944            settings.drive.accounts["work"].chrome_profile_from_email,
1945            "the bool field must deserialize back to `true`, not the string \"true\""
1946        );
1947    }
1948
1949    #[test]
1950    fn remove_drive_account_true_when_present_false_when_absent() {
1951        let (_tmp, path) = temp_settings_path();
1952        fs::create_dir_all(path.parent().unwrap()).unwrap();
1953        fs::write(
1954            &path,
1955            r#"{"drive": {"accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
1956        )
1957        .unwrap();
1958
1959        assert!(Settings::remove_drive_account(&path, "work").unwrap());
1960        let val = read_json(&path);
1961        assert!(val["drive"]["accounts"].get("work").is_none());
1962        assert_eq!(val["drive"]["accounts"]["personal"]["client_id"], "keep");
1963
1964        assert!(!Settings::remove_drive_account(&path, "work").unwrap());
1965    }
1966
1967    #[test]
1968    fn remove_drive_account_clears_default_account_when_it_named_the_removed_account() {
1969        let (_tmp, path) = temp_settings_path();
1970        fs::create_dir_all(path.parent().unwrap()).unwrap();
1971        fs::write(
1972            &path,
1973            r#"{"drive": {"default_account": "work", "accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
1974        )
1975        .unwrap();
1976
1977        assert!(Settings::remove_drive_account(&path, "work").unwrap());
1978        let val = read_json(&path);
1979        assert!(val["drive"].get("default_account").is_none());
1980        assert_eq!(val["drive"]["accounts"]["personal"]["client_id"], "keep");
1981    }
1982
1983    #[test]
1984    fn remove_drive_account_leaves_default_account_untouched_when_it_names_a_different_account() {
1985        let (_tmp, path) = temp_settings_path();
1986        fs::create_dir_all(path.parent().unwrap()).unwrap();
1987        fs::write(
1988            &path,
1989            r#"{"drive": {"default_account": "personal", "accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
1990        )
1991        .unwrap();
1992
1993        assert!(Settings::remove_drive_account(&path, "work").unwrap());
1994        let val = read_json(&path);
1995        assert_eq!(val["drive"]["default_account"], "personal");
1996    }
1997
1998    #[test]
1999    fn remove_drive_account_false_when_file_missing() {
2000        let (_tmp, path) = temp_settings_path();
2001        assert!(!Settings::remove_drive_account(&path, "work").unwrap());
2002        assert!(!path.exists());
2003    }
2004
2005    #[test]
2006    fn set_drive_default_account_sets_and_clears() {
2007        let (_tmp, path) = temp_settings_path();
2008
2009        Settings::set_drive_default_account(&path, Some("work")).unwrap();
2010        assert_eq!(read_json(&path)["drive"]["default_account"], "work");
2011
2012        Settings::set_drive_default_account(&path, None).unwrap();
2013        assert!(read_json(&path)["drive"].get("default_account").is_none());
2014    }
2015}