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