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, SecretTriple, SystemEnv};
25use crate::utils::secret_env;
26
27/// Where a resolved environment value came from, for provenance reporting
28/// (issue #1143).
29///
30/// An ambient setting — a shell export or a `settings.json` `env` entry — is
31/// sticky across invocations, so warnings about security-sensitive values
32/// (e.g. the claude-cli escape hatches) name the source to distinguish a
33/// deliberate one-off flag from a forgotten persistent setting.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum EnvValueSource {
36    /// Exported into the process environment by a command-line flag during
37    /// this invocation (see `AiBackendArgs::apply`).
38    CliFlag,
39    /// The process environment (a shell export or inherited variable).
40    ProcessEnv,
41    /// The base `env` map in `$HOME/.omni-dev/settings.json`.
42    SettingsEnv,
43    /// The named profile's `env` map in `$HOME/.omni-dev/settings.json`.
44    SettingsProfile(String),
45}
46
47impl fmt::Display for EnvValueSource {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match self {
50            Self::CliFlag => write!(f, "command-line flag"),
51            Self::ProcessEnv => write!(f, "process environment variable (e.g. a shell export)"),
52            Self::SettingsEnv => write!(f, "the env map in $HOME/.omni-dev/settings.json"),
53            Self::SettingsProfile(name) => {
54                write!(
55                    f,
56                    "the profile '{name}' env map in $HOME/.omni-dev/settings.json"
57                )
58            }
59        }
60    }
61}
62
63/// Env-var keys that `AiBackendArgs::apply` exported from command-line
64/// flags this invocation. Additive-only, written once at startup, so readers
65/// can attribute a process-env hit to the flag that set it rather than to an
66/// ambient shell export. Not an env-mutation seam: tests exercise the sourced
67/// resolvers through their injected `from_cli_flag` parameter instead.
68static CLI_FLAG_EXPORTS: Mutex<BTreeSet<String>> = Mutex::new(BTreeSet::new());
69
70/// Records that `key` was exported into the process environment by a
71/// command-line flag, so [`get_env_var_sourced`] reports
72/// [`EnvValueSource::CliFlag`] for it instead of
73/// [`EnvValueSource::ProcessEnv`].
74pub fn note_cli_flag_export(key: &str) {
75    // Recover from poisoning rather than losing provenance: the set is
76    // insert-only, so a panicked writer cannot leave it inconsistent.
77    let mut set = CLI_FLAG_EXPORTS
78        .lock()
79        .unwrap_or_else(std::sync::PoisonError::into_inner);
80    set.insert(key.to_string());
81}
82
83/// Returns whether `key` was exported by a command-line flag this invocation.
84#[must_use]
85pub fn exported_by_cli_flag(key: &str) -> bool {
86    CLI_FLAG_EXPORTS
87        .lock()
88        .unwrap_or_else(std::sync::PoisonError::into_inner)
89        .contains(key)
90}
91
92/// Environment variable that selects the active profile, mirroring `AWS_PROFILE`.
93///
94/// Read from the **raw** process environment only (never through the profile
95/// fallback, which would be circular); the `--profile` flag propagates its value
96/// here in `Cli::propagate_profile_flag`.
97pub const PROFILE_ENV_VAR: &str = "OMNI_DEV_PROFILE";
98
99/// A named credential/config bundle inside `settings.json` — its own `env` map,
100/// selected per invocation via `--profile` / `OMNI_DEV_PROFILE`.
101#[derive(Debug, Default, Deserialize)]
102pub struct Profile {
103    /// Environment variable overrides applied when this profile is active.
104    #[serde(default)]
105    pub env: HashMap<String, String>,
106}
107
108/// The `mcp` section of `settings.json` — defaults for the `omni-dev-mcp`
109/// server (issue #620).
110///
111/// Every field is optional; an unset field falls back to the built-in default,
112/// so an absent `mcp` block preserves the server's behaviour byte-for-byte. See
113/// [`Settings::load_mcp`] for the loader and `crate::mcp` for the wiring.
114#[derive(Debug, Default, Deserialize)]
115pub struct McpSettings {
116    /// Default AI model for the `ai_chat` tool, used when the tool's own
117    /// `model` parameter is absent. Falls back to the model registry default.
118    #[serde(default)]
119    pub default_model: Option<String>,
120
121    /// Default tracing directive for the server (e.g. `"info"`,
122    /// `"omni_dev::mcp=debug"`). `RUST_LOG` overrides this when set; the
123    /// built-in fallback is `"warn"`.
124    #[serde(default)]
125    pub log_level: Option<String>,
126
127    /// Default cap, in bytes, on an MCP tool response before it is truncated.
128    /// Falls back to the server's built-in `DEFAULT_MAX_RESPONSE_BYTES`
129    /// (100 KB). A value of `0` disables truncation.
130    #[serde(default)]
131    pub max_response_bytes: Option<usize>,
132}
133
134/// A single named Gmail account's stored OAuth2 credentials, inside the
135/// `gmail.accounts` map (issue #1500, [ADR-0066](../../docs/adrs/adr-0066.md)).
136///
137/// Orthogonal to [`Profile`]: selecting a Gmail account never changes the
138/// active `--profile`, and vice versa. See `email_address`'s own doc
139/// comment below for how it's populated and used — never for
140/// authentication itself.
141#[derive(Debug, Default, Deserialize)]
142pub struct GmailAccountSettings {
143    /// OAuth2 client id from the account's Google Cloud project.
144    #[serde(default)]
145    pub client_id: Option<String>,
146    /// OAuth2 client secret from the account's Google Cloud project.
147    #[serde(default)]
148    pub client_secret: Option<String>,
149    /// Absolute path to a file holding `client_secret` instead (#2008) —
150    /// the raw secret, not a `client_secret.json`. Setting both is an error.
151    #[serde(default)]
152    pub client_secret_file: Option<String>,
153    /// The long-lived refresh token obtained by `gmail auth login`.
154    #[serde(default)]
155    pub refresh_token: Option<String>,
156    /// Absolute path to a file holding `refresh_token` instead (#2008);
157    /// `gmail auth login` writes a new token there. Setting both is an
158    /// error.
159    #[serde(default)]
160    pub refresh_token_file: Option<String>,
161    /// The OAuth2 scope this account was authorized with.
162    #[serde(default)]
163    pub scope: Option<String>,
164    /// Mailbox address for this account. Populated opportunistically by
165    /// `gmail auth status` when absent, but may also be set by hand ahead of
166    /// the first login — e.g. to opt into `chrome_profile_from_email` below.
167    /// An explicit value is never overwritten by the `gmail auth status`
168    /// backfill. Never used for authentication itself, only for browser
169    /// targeting.
170    #[serde(default)]
171    pub email_address: Option<String>,
172
173    /// Opt-in (default `false`): resolve which local Chrome profile is
174    /// signed into `email_address` and launch `gmail auth login`'s
175    /// authorization URL targeting that profile, instead of the OS default
176    /// browser. Resolution failure (Chrome not installed, zero or multiple
177    /// matching profiles, ...) always falls back to the default browser —
178    /// never a hard login failure. Ignored when `browser_command` is set.
179    /// (issue #1505, [ADR-0067](../../docs/adrs/adr-0067.md))
180    #[serde(default)]
181    pub chrome_profile_from_email: bool,
182
183    /// Explicit browser launch command for `gmail auth login`, `{url}`
184    /// templated (or appended if no placeholder is present) — the manual
185    /// escape hatch, e.g. to target a specific Chrome profile by hand or a
186    /// non-Chrome browser entirely. Mirrors `SNOWFLAKE_BROWSER_COMMAND`
187    /// (`crate::snowflake`). Takes precedence over `chrome_profile_from_email`.
188    /// (issue #1505)
189    #[serde(default)]
190    pub browser_command: Option<String>,
191}
192
193/// The `gmail` section of `settings.json` — named Gmail accounts, selected
194/// per invocation via `--account` / `OMNI_DEV_GMAIL_ACCOUNT`
195/// (issue #1500, [ADR-0066](../../docs/adrs/adr-0066.md)).
196///
197/// An absent `gmail` block (or an empty `accounts` map) leaves Gmail
198/// credential resolution on today's exact legacy path — see
199/// `crate::gmail::account::resolve_account`.
200#[derive(Debug, Default, Deserialize)]
201pub struct GmailSettings {
202    /// The account `gmail account list`/credential resolution falls back to
203    /// when `--account`/`OMNI_DEV_GMAIL_ACCOUNT` is unset and more than one
204    /// account is configured.
205    #[serde(default)]
206    pub default_account: Option<String>,
207
208    /// Named accounts, keyed by the name passed to `--account`.
209    #[serde(default)]
210    pub accounts: HashMap<String, GmailAccountSettings>,
211}
212
213/// A single named Google Drive account's stored OAuth2 credentials, inside
214/// the `drive.accounts` map (issue #1520,
215/// [ADR-0069](../../docs/adrs/adr-0069.md)).
216///
217/// A field-for-field mirror of [`GmailAccountSettings`]: a Drive account and
218/// a Gmail account are different credential axes even when the underlying
219/// human is the same person, so the two blocks compose independently rather
220/// than one nesting under the other. See `email_address`'s own doc comment
221/// below for how it's populated and used — never for authentication itself.
222#[derive(Debug, Default, Deserialize)]
223pub struct DriveAccountSettings {
224    /// OAuth2 client id from the account's Google Cloud project.
225    #[serde(default)]
226    pub client_id: Option<String>,
227    /// OAuth2 client secret from the account's Google Cloud project.
228    #[serde(default)]
229    pub client_secret: Option<String>,
230    /// Absolute path to a file holding `client_secret` instead (#2008).
231    /// Setting both is an error.
232    #[serde(default)]
233    pub client_secret_file: Option<String>,
234    /// The long-lived refresh token obtained by `drive auth login`
235    /// (issue #1523).
236    #[serde(default)]
237    pub refresh_token: Option<String>,
238    /// Absolute path to a file holding `refresh_token` instead (#2008);
239    /// `drive auth login` writes a new token there. Setting both is an
240    /// error.
241    #[serde(default)]
242    pub refresh_token_file: Option<String>,
243    /// The OAuth2 scope this account was authorized with, as the raw string
244    /// Google granted. Stays a plain `String` here (unlike Gmail's
245    /// equivalent settings field, for the same reason): this is a
246    /// status-reporting cache of whatever was actually granted, not the
247    /// value driving a live login decision — that's
248    /// [`crate::drive::auth::DriveGrantedScopes`], read from this field via
249    /// [`crate::drive::auth::DriveGrantedScopes::from_granted`]
250    /// ([ADR-0070](../../docs/adrs/adr-0070.md), reversing
251    /// [ADR-0069](../../docs/adrs/adr-0069.md) §2's "no `DriveScope` enum,
252    /// read-only by design"; further generalized from a 2-variant enum to a
253    /// capability set by issue #1574).
254    #[serde(default)]
255    pub scope: Option<String>,
256    /// Google account address for this account. Populated opportunistically
257    /// by `drive auth status` when absent, but may also be set by hand ahead
258    /// of the first login — e.g. to opt into `chrome_profile_from_email`
259    /// below. An explicit value is never overwritten by the backfill. Never
260    /// used for authentication itself, only for browser targeting.
261    #[serde(default)]
262    pub email_address: Option<String>,
263
264    /// Opt-in (default `false`): resolve which local Chrome profile is
265    /// signed into `email_address` and launch `drive auth login`'s
266    /// authorization URL targeting that profile, instead of the OS default
267    /// browser. Resolution failure (Chrome not installed, zero or multiple
268    /// matching profiles, ...) always falls back to the default browser —
269    /// never a hard login failure. Ignored when `browser_command` is set.
270    /// (Inherited from Gmail by field —
271    /// [ADR-0067](../../docs/adrs/adr-0067.md) via
272    /// [ADR-0069](../../docs/adrs/adr-0069.md).)
273    #[serde(default)]
274    pub chrome_profile_from_email: bool,
275
276    /// Explicit browser launch command for `drive auth login`, `{url}`
277    /// templated (or appended if no placeholder is present) — the manual
278    /// escape hatch, e.g. to target a specific Chrome profile by hand or a
279    /// non-Chrome browser entirely. Mirrors `SNOWFLAKE_BROWSER_COMMAND`
280    /// (`crate::snowflake`). Takes precedence over
281    /// `chrome_profile_from_email`. (Inherited from Gmail by field —
282    /// [ADR-0067](../../docs/adrs/adr-0067.md).)
283    #[serde(default)]
284    pub browser_command: Option<String>,
285
286    /// Destination folder id for the Drive-side backup copy a native
287    /// (Google Sheets/Docs/Slides) `drive lease acquire` makes
288    /// ([ADR-0080](../../docs/adrs/adr-0080.md) §3) — per-account, for the
289    /// identical reason `write_permissions` is: a folder id only means
290    /// something inside the one Drive it was minted in. Absent means
291    /// `drive lease acquire` refuses every native-document target for this
292    /// account outright (`RefusedNativeDocument`), since there is nowhere
293    /// configured to put the copy.
294    #[serde(default)]
295    pub lease_backup_folder_id: Option<String>,
296
297    /// Folder-scoped write-permission rules gating `drive create`/`upload`/
298    /// `edit` for this account (issue #1574).
299    ///
300    /// Per-account, not a top-level `DriveSettings` field: a folder id is
301    /// only meaningful inside the one Drive it was minted in, exactly like
302    /// `client_id`/`refresh_token`/`scope` above are already per-account —
303    /// see [`crate::drive::write_gate`]'s module doc for the gate itself.
304    /// Absent or empty means every write is refused for this account
305    /// everywhere; there is deliberately no separate enabled/disabled
306    /// toggle (falls out of
307    /// [`crate::drive::write_gate::DriveOperation`]'s default policy
308    /// alone).
309    #[serde(default)]
310    pub write_permissions: WritePermissionsSettings,
311}
312
313/// The `write_permissions` block on one [`DriveAccountSettings`] — see its
314/// doc comment for why this is per-account.
315#[derive(Debug, Default, Deserialize)]
316pub struct WritePermissionsSettings {
317    /// The configured rules, evaluated by
318    /// [`crate::drive::write_gate::resolve`].
319    #[serde(default)]
320    pub rules: Vec<crate::drive::write_gate::FolderPermissionRule>,
321}
322
323/// The `drive` section of `settings.json` — named Google Drive accounts,
324/// selected per invocation via `--account` / `OMNI_DEV_DRIVE_ACCOUNT`
325/// (issue #1520, [ADR-0069](../../docs/adrs/adr-0069.md)).
326///
327/// An absent `drive` block (or an empty `accounts` map) means Drive is
328/// simply unconfigured — unlike [`GmailSettings`], there is no legacy
329/// credential path to fall back to, Drive being brand new.
330#[derive(Debug, Default, Deserialize)]
331pub struct DriveSettings {
332    /// The account credential resolution falls back to when
333    /// `--account`/`OMNI_DEV_DRIVE_ACCOUNT` is unset and more than one
334    /// account is configured.
335    #[serde(default)]
336    pub default_account: Option<String>,
337
338    /// Named accounts, keyed by the name passed to `--account`.
339    #[serde(default)]
340    pub accounts: HashMap<String, DriveAccountSettings>,
341}
342
343/// The `lease` section of `settings.json` — global, machine-wide policy for
344/// `drive lease acquire`/`restore`.
345///
346/// Sibling of `drive` rather than nested under it (issue #1677,
347/// [ADR-0080](../../docs/adrs/adr-0080.md) §13): none of these are
348/// meaningful per-Drive-account, they describe *this machine's* policy.
349/// Every field is optional; an unset field falls back to the built-in
350/// default, so an absent `lease` block preserves `drive lease`'s behaviour
351/// byte-for-byte. See `crate::drive::lease::settings` for the resolvers that
352/// layer a CLI flag and an env var on top of each of these.
353#[derive(Debug, Default, Clone, Deserialize)]
354pub struct LeaseSettings {
355    /// Default `--expiry-minutes` when the flag is omitted (ADR-0080 §5/§13).
356    #[serde(default)]
357    pub default_expiry_minutes: Option<i64>,
358
359    /// Default `--backup-dir` when the flag is omitted (ADR-0080 §3/§13).
360    #[serde(default)]
361    pub backup_dir: Option<PathBuf>,
362
363    /// Default authentication policy: `false` selects device-owner (the
364    /// default), `true` selects biometrics-only (ADR-0080 §7/§13).
365    #[serde(default)]
366    pub biometrics_only: bool,
367
368    /// Opt-out for headless/off-macOS contexts (ADR-0080 §8/§13): `true`
369    /// lets `drive lease acquire`/`restore` proceed with no device-owner
370    /// authenticator, waiving the human-presence guarantee.
371    #[serde(default)]
372    pub allow_headless: bool,
373}
374
375/// Defaults for the long-lived daemon, independent of the launcher's environment.
376#[derive(Debug, Default, Deserialize)]
377pub struct DaemonSettings {
378    /// Tracing directive (e.g. `"info"` or `"omni_dev::sessions=debug"`).
379    /// A valid `RUST_LOG` overrides this; the built-in fallback is `"info"`.
380    #[serde(default)]
381    pub log_level: Option<String>,
382}
383
384/// Settings loaded from $HOME/.omni-dev/settings.json.
385#[derive(Debug, Default, Deserialize)]
386pub struct Settings {
387    /// Environment variable overrides — the default bundle, consulted only when
388    /// **no** profile is active.
389    #[serde(default)]
390    pub env: HashMap<String, String>,
391
392    /// Named profiles. Selecting one replaces the base `env` in the fallback
393    /// chain (isolated / AWS-faithful); see [`Settings::resolve_with`].
394    #[serde(default)]
395    pub profiles: HashMap<String, Profile>,
396
397    /// MCP server defaults (issue #620); an absent block yields
398    /// [`McpSettings::default`].
399    #[serde(default)]
400    pub mcp: McpSettings,
401
402    /// Daemon tracing defaults; an absent section preserves the built-in filter.
403    #[serde(default)]
404    pub daemon: DaemonSettings,
405
406    /// Named Gmail accounts (issue #1500); an absent block yields
407    /// [`GmailSettings::default`], which is an empty account map.
408    #[serde(default)]
409    pub gmail: GmailSettings,
410
411    /// Named Google Drive accounts (issue #1520); an absent block yields
412    /// [`DriveSettings::default`], which is an empty account map.
413    #[serde(default)]
414    pub drive: DriveSettings,
415
416    /// Global Drive lease policy (issue #1677); an absent block yields
417    /// [`LeaseSettings::default`].
418    #[serde(default)]
419    pub lease: LeaseSettings,
420}
421
422/// Returns the active profile name from `raw` (the process environment), or
423/// `None` when `OMNI_DEV_PROFILE` is unset or empty.
424///
425/// Reads the **raw** env only, so it is pure over the injected source and never
426/// resolves through the profile fallback.
427pub fn active_profile_from<E: EnvSource>(raw: &E) -> Option<String> {
428    raw.var(PROFILE_ENV_VAR).filter(|s| !s.is_empty())
429}
430
431/// Renders ` (profile '<name>')` for credential-store CLI messages, or the
432/// empty string when no profile is active — so `auth login`/`logout` output
433/// names the env map it actually wrote to (issue #1116).
434#[must_use]
435pub fn profile_suffix(profile: Option<&str>) -> String {
436    profile.map_or_else(String::new, |name| format!(" (profile '{name}')"))
437}
438
439/// An [`EnvSource`](crate::utils::env::EnvSource) with the settings/profile
440/// fallback — the value form of [`get_env_var`].
441///
442/// Reads the real process environment first, then the active profile's `env`
443/// (or the base `env` when no profile is active) in
444/// `$HOME/.omni-dev/settings.json`.
445///
446/// Pass `&SettingsEnv::load()` from a thin production wrapper; tests inject a
447/// pure `MapEnv` into the same `*_with(&impl EnvSource, …)` seam instead of
448/// mutating the process environment.
449#[derive(Debug, Default)]
450pub struct SettingsEnv {
451    settings: Settings,
452    active_profile: Option<String>,
453}
454
455impl SettingsEnv {
456    /// Loads settings from the default location, falling back to an empty
457    /// settings map if they are absent or unreadable (env-only behaviour),
458    /// warning first if the file exists but fails to parse (issue #1744).
459    /// The active profile is read from `OMNI_DEV_PROFILE`.
460    pub fn load() -> Self {
461        Self::load_with_profile(active_profile_from(&SystemEnv).as_deref())
462    }
463
464    /// Like [`load`](Self::load) but with the active profile supplied
465    /// explicitly — for tests and embedders that select a profile without
466    /// setting `OMNI_DEV_PROFILE` in the process environment.
467    pub fn load_with_profile(profile: Option<&str>) -> Self {
468        Self {
469            settings: Settings::load_or_warn_default(),
470            active_profile: profile.map(str::to_string),
471        }
472    }
473
474    /// Wraps an already-loaded [`Settings`], skipping the disk read/parse
475    /// [`load`](Self::load)/[`load_with_profile`](Self::load_with_profile)
476    /// perform — for callers (e.g. `load_credentials_for`/`status_for` in
477    /// `crate::gmail::auth`/`crate::drive::auth`) that already loaded
478    /// `Settings` to resolve an account and would otherwise discard it just
479    /// to re-read the same file a second time (issue #1533).
480    pub fn from_settings(settings: Settings, profile: Option<&str>) -> Self {
481        Self {
482            settings,
483            active_profile: profile.map(str::to_string),
484        }
485    }
486}
487
488impl EnvSource for SettingsEnv {
489    fn var(&self, key: &str) -> Option<String> {
490        self.settings
491            .resolve_with(&SystemEnv, self.active_profile.as_deref(), key)
492    }
493
494    fn var_triple(&self, key: &str, file_key: &str, command_key: &str) -> SecretTriple {
495        self.settings.resolve_triple_with(
496            &SystemEnv,
497            self.active_profile.as_deref(),
498            key,
499            file_key,
500            command_key,
501        )
502    }
503}
504
505/// A borrowed [`SettingsEnv`]: the settings/profile fallback over an
506/// already-loaded [`Settings`], for callers that keep using the `Settings`
507/// afterwards. Built by [`Settings::env_source`].
508#[derive(Debug)]
509pub struct SettingsEnvRef<'a> {
510    settings: &'a Settings,
511    active_profile: Option<String>,
512}
513
514impl EnvSource for SettingsEnvRef<'_> {
515    fn var(&self, key: &str) -> Option<String> {
516        self.settings
517            .resolve_with(&SystemEnv, self.active_profile.as_deref(), key)
518    }
519
520    fn var_triple(&self, key: &str, file_key: &str, command_key: &str) -> SecretTriple {
521        self.settings.resolve_triple_with(
522            &SystemEnv,
523            self.active_profile.as_deref(),
524            key,
525            file_key,
526            command_key,
527        )
528    }
529}
530
531/// Process-wide de-duplication for [`Settings::load_or_warn_default`]'s
532/// warning (issue #1744). Remembers the last failure warned about: a repeat
533/// of the same failure is silent, while a successful load forgets it — so a
534/// long-lived process (the daemon, the MCP server) warns afresh if the file is
535/// fixed and later broken again, even with an identical error.
536struct LoadWarnDedup(Mutex<Option<String>>);
537
538impl LoadWarnDedup {
539    const fn new() -> Self {
540        Self(Mutex::new(None))
541    }
542
543    /// Records one load's outcome (`None` = success, `Some` = the failure's
544    /// message) and returns whether that failure should be warned about.
545    fn observe(&self, failure: Option<&str>) -> bool {
546        let mut last = self
547            .0
548            .lock()
549            .unwrap_or_else(std::sync::PoisonError::into_inner);
550        match failure {
551            None => {
552                *last = None;
553                false
554            }
555            Some(message) if last.as_deref() == Some(message) => false,
556            Some(message) => {
557                *last = Some(message.to_string());
558                true
559            }
560        }
561    }
562}
563
564static LOAD_WARN_DEDUP: LoadWarnDedup = LoadWarnDedup::new();
565
566/// Warns that settings failed to load and defaults are being used, unless
567/// `dedup` has already reported this exact failure. Takes the dedup as a
568/// parameter so a test can assert the once-only behaviour on a private one.
569fn warn_settings_fallback(dedup: &LoadWarnDedup, message: &str) {
570    if dedup.observe(Some(message)) {
571        tracing::warn!(
572            "{message}; falling back to default settings for this invocation — \
573             any settings.json configuration is being ignored"
574        );
575    }
576}
577
578/// Whether `key` is a registered secret, so it has a `_FILE` companion.
579fn is_secret_env_var(key: &str) -> bool {
580    secret_env::SECRET_ENV_VARS.contains(&key)
581}
582
583/// Refuses to overwrite a registered secret that `env` fetches through its
584/// `<NAME>_COMMAND` companion (ADR-0090). Unlike a `_FILE` companion, which a
585/// login replaces (ADR-0089), the command names a store the user chose
586/// precisely to keep the secret out of settings.json; writing the new value
587/// there in plaintext would silently undo that. An empty value counts as
588/// unset, as everywhere else.
589fn refuse_secret_commands<'a>(
590    env: &serde_json::Map<String, serde_json::Value>,
591    keys: impl Iterator<Item = &'a str>,
592    profile: Option<&str>,
593) -> Result<()> {
594    for key in keys.filter(|key| is_secret_env_var(key)) {
595        let command_var = secret_env::command_var_name(key);
596        let set = env
597            .get(&command_var)
598            .and_then(serde_json::Value::as_str)
599            .is_some_and(|v| !v.is_empty());
600        if set {
601            let map = match profile {
602                Some(name) => format!("profiles.{name}.env"),
603                None => "env".to_string(),
604            };
605            anyhow::bail!(
606                "{key} is fetched by {command_var} in the {map} map of settings.json, so a login \
607                 would replace it with a plaintext value there and nothing was saved; store the \
608                 new value in the store {command_var} reads, or remove {command_var} first"
609            );
610        }
611    }
612    Ok(())
613}
614
615impl Settings {
616    /// Loads settings from the default location.
617    pub fn load() -> Result<Self> {
618        let settings_path = Self::get_settings_path()?;
619        Self::load_from_path(&settings_path)
620    }
621
622    /// Loads settings from the default location, warning and falling back to
623    /// [`Settings::default`] if the file exists but fails to read or parse —
624    /// the disk boundary every non-`Result` call site shares (issue #1744).
625    /// A *missing* file is not an error and never warns (see
626    /// [`Self::load_from_path`]).
627    ///
628    /// The warning fires once per distinct failure per process, not once per
629    /// call: a single command loads settings several times (client creation,
630    /// then each account/lease helper), and repeating an identical warning
631    /// for every read buried the refusal it explains. A successful load resets
632    /// this, so a long-lived process warns again if the file breaks again.
633    pub fn load_or_warn_default() -> Self {
634        match Self::load() {
635            Ok(settings) => {
636                LOAD_WARN_DEDUP.observe(None);
637                settings
638            }
639            Err(e) => {
640                warn_settings_fallback(&LOAD_WARN_DEDUP, &format!("{e:#}"));
641                Self::default()
642            }
643        }
644    }
645
646    /// Loads just the [`mcp`](McpSettings) section, warning and falling back
647    /// to its defaults when the settings file is absent or unreadable — so
648    /// the MCP server always boots even with a malformed `settings.json`
649    /// (issue #620), same warn-then-default contract as
650    /// [`Self::load_or_warn_default`] (issue #1744).
651    pub fn load_mcp() -> McpSettings {
652        Self::load_or_warn_default().mcp
653    }
654
655    /// Loads daemon defaults with the shared warn-and-default contract.
656    pub fn load_daemon() -> DaemonSettings {
657        Self::load_or_warn_default().daemon
658    }
659
660    /// Records a bootstrap settings failure after tracing has been installed.
661    /// Shares deduplication with later settings reads in the same process.
662    pub fn warn_bootstrap_failure(message: &str) {
663        warn_settings_fallback(&LOAD_WARN_DEDUP, message);
664    }
665
666    /// Loads settings from a specific path.
667    pub fn load_from_path<P: AsRef<Path>>(path: P) -> Result<Self> {
668        let path = path.as_ref();
669
670        // If file doesn't exist, return default settings
671        if !path.exists() {
672            return Ok(Self::default());
673        }
674
675        // Read and parse the settings file
676        let content = fs::read_to_string(path)
677            .with_context(|| format!("Failed to read settings file: {}", path.display()))?;
678
679        serde_json::from_str::<Self>(&content)
680            .with_context(|| format!("Failed to parse settings file: {}", path.display()))
681    }
682
683    /// Returns the default settings path.
684    pub fn get_settings_path() -> Result<PathBuf> {
685        let home_dir = dirs::home_dir().context("Failed to determine home directory")?;
686
687        Ok(home_dir.join(".omni-dev").join("settings.json"))
688    }
689
690    /// Returns an environment variable with fallback to settings, honouring the
691    /// active profile from `OMNI_DEV_PROFILE`.
692    pub fn get_env_var(&self, key: &str) -> Option<String> {
693        self.resolve_with(&SystemEnv, active_profile_from(&SystemEnv).as_deref(), key)
694    }
695
696    /// Isolated / AWS-faithful resolution: `raw` (the process environment) wins;
697    /// then the active profile's `env` if `active` is set, else the base `env`.
698    /// The base map is **not** consulted when a profile is active, so a missing
699    /// key fails loud rather than silently reusing a default credential against
700    /// the wrong tenant.
701    ///
702    /// This is the pure seam: production wrappers pass `&SystemEnv`; tests pass
703    /// a `MapEnv` and an explicit `active`, mutating no process-global state.
704    pub fn resolve_with<E: EnvSource>(
705        &self,
706        raw: &E,
707        active: Option<&str>,
708        key: &str,
709    ) -> Option<String> {
710        self.resolve_with_source(raw, active, key)
711            .map(|(value, _)| value)
712    }
713
714    /// Resolves `key`, `file_key` and `command_key` **as a triple from one
715    /// layer**, for the secret resolver ([`crate::utils::secret_env`],
716    /// ADR-0089, ADR-0090): the raw environment's triple if it sets any member
717    /// (even to an empty value, which neutralises the lower layers exactly as
718    /// [`Settings::resolve_with`] does); otherwise the active profile's `env`
719    /// triple, or the base `env` triple when no profile is active. So
720    /// `NAME_FILE` in the process env overrides `NAME` in settings.json, while
721    /// two members in one layer are left for the resolver to reject.
722    pub fn resolve_triple_with<E: EnvSource>(
723        &self,
724        raw: &E,
725        active: Option<&str>,
726        key: &str,
727        file_key: &str,
728        command_key: &str,
729    ) -> SecretTriple {
730        let raw_triple = (raw.var(key), raw.var(file_key), raw.var(command_key));
731        if raw_triple.0.is_some() || raw_triple.1.is_some() || raw_triple.2.is_some() {
732            return raw_triple;
733        }
734        let layer = match active {
735            Some(name) => self.profiles.get(name).map(|p| &p.env),
736            None => Some(&self.env),
737        };
738        layer.map_or((None, None, None), |env| {
739            (
740                env.get(key).cloned(),
741                env.get(file_key).cloned(),
742                env.get(command_key).cloned(),
743            )
744        })
745    }
746
747    /// An [`EnvSource`] over these settings with the process environment in
748    /// front and the active profile read from `OMNI_DEV_PROFILE` — the
749    /// borrowed form of [`SettingsEnv`], for the secret resolver.
750    #[must_use]
751    pub fn env_source(&self) -> SettingsEnvRef<'_> {
752        SettingsEnvRef {
753            settings: self,
754            active_profile: active_profile_from(&SystemEnv),
755        }
756    }
757
758    /// Like [`Settings::resolve_with`], but also reports which layer supplied
759    /// the value: the raw process environment, the active profile's `env`, or
760    /// the base `env` (issue #1143). Same precedence, same profile isolation.
761    ///
762    /// A [`EnvValueSource::CliFlag`] attribution is layered on top by
763    /// [`get_env_var_sourced`], which knows about flag exports; this resolver
764    /// only distinguishes what it can see.
765    pub fn resolve_with_source<E: EnvSource>(
766        &self,
767        raw: &E,
768        active: Option<&str>,
769        key: &str,
770    ) -> Option<(String, EnvValueSource)> {
771        if let Some(value) = raw.var(key) {
772            return Some((value, EnvValueSource::ProcessEnv));
773        }
774        match active {
775            Some(name) => self
776                .profiles
777                .get(name)
778                .and_then(|p| p.env.get(key).cloned())
779                .map(|value| (value, EnvValueSource::SettingsProfile(name.to_string()))),
780            None => self
781                .env
782                .get(key)
783                .cloned()
784                .map(|value| (value, EnvValueSource::SettingsEnv)),
785        }
786    }
787
788    /// Merges the given key/value pairs into the base `env` object of the
789    /// settings file at `path` — [`Settings::upsert_env_vars_in`] with no
790    /// profile.
791    pub fn upsert_env_vars(path: &Path, vars: &[(&str, &str)]) -> Result<()> {
792        Self::upsert_env_vars_in(path, None, vars)
793    }
794
795    /// Merges the given key/value pairs into the `env` object targeted by
796    /// `profile` — `profiles.<name>.env` when `Some`, the base `env` when
797    /// `None` — creating the file, its parent directory, and any missing
798    /// intermediate objects as needed. Writes therefore land where
799    /// [`Settings::resolve_with`] will look for them (issue #1116).
800    ///
801    /// A `profile` absent from the file is created rather than rejected; the
802    /// CLI validates the active profile before dispatch, so this only affects
803    /// library callers.
804    ///
805    /// The file is read and written as a generic JSON value, so every other
806    /// field (other profiles, unknown keys) is preserved verbatim. Because the
807    /// `env` maps hold credentials, the write is hardened: parent directory
808    /// `0700`, file `0600` (see [`write_settings`]).
809    pub fn upsert_env_vars_in(
810        path: &Path,
811        profile: Option<&str>,
812        vars: &[(&str, &str)],
813    ) -> Result<()> {
814        Self::replace_env_vars_in(path, profile, vars, &[])
815    }
816
817    /// Replaces keys and removes obsolete credentials in one settings write.
818    ///
819    /// Preserves unrelated settings, rejects selected secret helpers before any
820    /// mutation, and removes obsolete secrets' file and command companions.
821    pub(crate) fn replace_env_vars_in(
822        path: &Path,
823        profile: Option<&str>,
824        vars: &[(&str, &str)],
825        remove: &[&str],
826    ) -> Result<()> {
827        let mut settings_value = read_or_default_settings(path)?;
828
829        let env = ensure_env_object(&mut settings_value, profile)?;
830        refuse_secret_commands(env, vars.iter().map(|(key, _)| *key), profile)?;
831        for key in remove {
832            env.remove(*key);
833            if is_secret_env_var(key) {
834                env.remove(&secret_env::file_var_name(key));
835                env.remove(&secret_env::command_var_name(key));
836            }
837        }
838        for (key, value) in vars {
839            env.insert(
840                (*key).to_string(),
841                serde_json::Value::String((*value).to_string()),
842            );
843            // A registered secret's `_FILE` companion in the same map would
844            // make every later read a same-layer conflict (ADR-0089), so the
845            // value just written replaces it.
846            if is_secret_env_var(key) {
847                env.remove(&secret_env::file_var_name(key));
848            }
849        }
850
851        write_settings(path, &settings_value)
852    }
853
854    /// Fails when any of the registered secrets in `keys` is fetched through
855    /// a `<NAME>_COMMAND`, either in the `env` object targeted by `profile`
856    /// (the check [`Settings::upsert_env_vars_in`] applies before writing) or
857    /// in the process environment `raw`, which outranks whatever a login would
858    /// write there and would silently shadow it. Exposed so an interactive
859    /// login can refuse *before* it spends a browser flow on a token it would
860    /// then be unable to use (ADR-0090). A missing file or map passes.
861    pub fn ensure_secrets_replaceable(
862        path: &Path,
863        profile: Option<&str>,
864        keys: &[&str],
865        raw: &impl EnvSource,
866    ) -> Result<()> {
867        for key in keys.iter().copied().filter(|key| is_secret_env_var(key)) {
868            let command_var = secret_env::command_var_name(key);
869            if raw.var(&command_var).is_some_and(|v| !v.is_empty()) {
870                anyhow::bail!(
871                    "{key} is fetched by {command_var}, which is set in the environment and \
872                     would shadow anything a login saved, so nothing was saved; store the new \
873                     value in the store {command_var} reads, or unset {command_var} first"
874                );
875            }
876        }
877        if !path.exists() {
878            return Ok(());
879        }
880        let mut settings_value = read_or_default_settings(path)?;
881        match env_object_mut(&mut settings_value, profile) {
882            Some(env) => refuse_secret_commands(env, keys.iter().copied(), profile),
883            None => Ok(()),
884        }
885    }
886
887    /// Removes the given keys from the base `env` object of the settings file
888    /// at `path` — [`Settings::remove_env_vars_in`] with no profile.
889    pub fn remove_env_vars(path: &Path, keys: &[&str]) -> Result<bool> {
890        Self::remove_env_vars_in(path, None, keys)
891    }
892
893    /// Removes the given keys from the `env` object targeted by `profile`
894    /// (`profiles.<name>.env` when `Some`, the base `env` when `None`),
895    /// leaving all other settings — including the same keys in other env
896    /// maps — intact.
897    ///
898    /// Returns `true` if any key was present in the targeted map and removed
899    /// (the file is rewritten, hardened as in
900    /// [`Settings::upsert_env_vars_in`]), `false` when the file did not
901    /// exist, the targeted map was absent, or it contained none of the keys
902    /// (the file is left untouched).
903    pub fn remove_env_vars_in(path: &Path, profile: Option<&str>, keys: &[&str]) -> Result<bool> {
904        if !path.exists() {
905            return Ok(false);
906        }
907        let mut settings_value = read_or_default_settings(path)?;
908
909        let mut removed = false;
910        if let Some(env) = env_object_mut(&mut settings_value, profile) {
911            for key in keys {
912                if env.remove(*key).is_some() {
913                    removed = true;
914                }
915                // Logging out must not leave a registered secret still
916                // resolvable through its `_FILE` or `_COMMAND` companion
917                // (ADR-0089, ADR-0090).
918                if is_secret_env_var(key) {
919                    for companion in [
920                        secret_env::file_var_name(key),
921                        secret_env::command_var_name(key),
922                    ] {
923                        if env.remove(&companion).is_some() {
924                            removed = true;
925                        }
926                    }
927                }
928            }
929        }
930
931        if removed {
932            write_settings(path, &settings_value)?;
933        }
934        Ok(removed)
935    }
936
937    /// Validates that `name` is a known profile, returning a hard error that
938    /// lists the known profiles (sorted) otherwise. Called once at the CLI
939    /// boundary so a typo never silently falls back to base credentials.
940    pub fn validate_profile(&self, name: &str) -> Result<()> {
941        if self.profiles.contains_key(name) {
942            return Ok(());
943        }
944        let known = if self.profiles.is_empty() {
945            "(none)".to_string()
946        } else {
947            let mut names: Vec<&str> = self.profiles.keys().map(String::as_str).collect();
948            names.sort_unstable();
949            names.join(", ")
950        };
951        Err(anyhow::anyhow!(
952            "unknown profile '{name}'; known profiles: {known}"
953        ))
954    }
955
956    /// Merges the given key/value pairs into `gmail.accounts.<account>`,
957    /// creating the file, its parent directory, and any missing intermediate
958    /// objects as needed (issue #1500,
959    /// [ADR-0066](../../docs/adrs/adr-0066.md)). Same hardening and
960    /// unknown-field preservation as [`Settings::upsert_env_vars_in`] — no
961    /// new file-handling code.
962    ///
963    /// `vars` takes [`serde_json::Value`] rather than `&str` (widened in
964    /// #1523, PR #1528 review) because `GmailAccountSettings` has non-string
965    /// fields (`chrome_profile_from_email: bool`) — a hard-coded
966    /// `Value::String` wrap would write the JSON string `"true"` into a
967    /// `bool` field, and since `Settings` deserializes as one unit, the next
968    /// `Settings::load()` would hard-fail parsing the *entire* file. Callers
969    /// writing a string field pass `serde_json::Value::String(...)`
970    /// explicitly.
971    pub fn upsert_gmail_account(
972        path: &Path,
973        account: &str,
974        vars: &[(&str, serde_json::Value)],
975    ) -> Result<()> {
976        upsert_account(path, "gmail", account, vars)
977    }
978
979    /// Removes `gmail.accounts.<account>` entirely — an account is coherent
980    /// as a unit, unlike the key-by-key removal
981    /// [`Settings::remove_env_vars_in`] does. Also clears
982    /// `gmail.default_account` if it named the removed account, so
983    /// resolution (`src/gmail/account.rs::resolve_default_account`) falls
984    /// back to the sole-remaining-account rule instead of hard-erroring on
985    /// a dangling pointer (issue #1529). Returns `true` if the account was
986    /// present and removed, `false` when the file, `gmail`,
987    /// `gmail.accounts`, or the named account did not exist (the file is
988    /// left untouched in that case).
989    pub fn remove_gmail_account(path: &Path, account: &str) -> Result<bool> {
990        if !path.exists() {
991            return Ok(false);
992        }
993        let mut settings_value = read_or_default_settings(path)?;
994
995        let removed = object_at_mut(&mut settings_value, &["gmail", "accounts"])
996            .is_some_and(|accounts| accounts.remove(account).is_some());
997
998        if removed {
999            if let Some(gmail) = object_at_mut(&mut settings_value, &["gmail"]) {
1000                if gmail.get("default_account").and_then(|v| v.as_str()) == Some(account) {
1001                    gmail.remove("default_account");
1002                }
1003            }
1004            write_settings(path, &settings_value)?;
1005        }
1006        Ok(removed)
1007    }
1008
1009    /// Sets (`Some`) or clears (`None`) `gmail.default_account`. Always
1010    /// writes, mirroring [`Settings::upsert_env_vars_in`]'s unconditional-write
1011    /// semantics rather than [`Settings::remove_env_vars_in`]'s
1012    /// changed-only one, since this is fundamentally an upsert of a single
1013    /// scalar rather than a set of keys.
1014    pub fn set_gmail_default_account(path: &Path, account: Option<&str>) -> Result<()> {
1015        let mut settings_value = read_or_default_settings(path)?;
1016
1017        match account {
1018            Some(name) => {
1019                let gmail = ensure_object_at(&mut settings_value, &["gmail"])?;
1020                gmail.insert(
1021                    "default_account".to_string(),
1022                    serde_json::Value::String(name.to_string()),
1023                );
1024            }
1025            None => {
1026                if let Some(gmail) = object_at_mut(&mut settings_value, &["gmail"]) {
1027                    gmail.remove("default_account");
1028                }
1029            }
1030        }
1031
1032        write_settings(path, &settings_value)
1033    }
1034
1035    /// Merges the given key/value pairs into `drive.accounts.<account>`,
1036    /// creating the file, its parent directory, and any missing intermediate
1037    /// objects as needed (issue #1522,
1038    /// [ADR-0069](../../docs/adrs/adr-0069.md)). Same hardening and
1039    /// unknown-field preservation as [`Settings::upsert_env_vars_in`] — no
1040    /// new file-handling code.
1041    ///
1042    /// `vars` takes [`serde_json::Value`], not `&str` — see
1043    /// [`Settings::upsert_gmail_account`]'s doc comment for why (this
1044    /// helper's twin, and the write path the PR #1528 review comment
1045    /// flagged the bug against).
1046    pub fn upsert_drive_account(
1047        path: &Path,
1048        account: &str,
1049        vars: &[(&str, serde_json::Value)],
1050    ) -> Result<()> {
1051        upsert_account(path, "drive", account, vars)
1052    }
1053
1054    /// Removes `drive.accounts.<account>` entirely — an account is coherent
1055    /// as a unit, exactly like [`Settings::remove_gmail_account`]. Also
1056    /// clears `drive.default_account` if it named the removed account, for
1057    /// the same dangling-pointer reason (issue #1529) — Drive has no
1058    /// legacy-credential fallback to soften a stale default the way
1059    /// Gmail's resolution table can (ADR-0069 §3), so the failure would be
1060    /// total. Returns `true` if the account was present and removed,
1061    /// `false` when the file, `drive`, `drive.accounts`, or the named
1062    /// account did not exist (the file is left untouched in that case).
1063    pub fn remove_drive_account(path: &Path, account: &str) -> Result<bool> {
1064        if !path.exists() {
1065            return Ok(false);
1066        }
1067        let mut settings_value = read_or_default_settings(path)?;
1068
1069        let removed = object_at_mut(&mut settings_value, &["drive", "accounts"])
1070            .is_some_and(|accounts| accounts.remove(account).is_some());
1071
1072        if removed {
1073            if let Some(drive) = object_at_mut(&mut settings_value, &["drive"]) {
1074                if drive.get("default_account").and_then(|v| v.as_str()) == Some(account) {
1075                    drive.remove("default_account");
1076                }
1077            }
1078            write_settings(path, &settings_value)?;
1079        }
1080        Ok(removed)
1081    }
1082
1083    /// Sets (`Some`) or clears (`None`) `drive.default_account`. Always
1084    /// writes, mirroring [`Settings::set_gmail_default_account`]'s
1085    /// unconditional-write semantics — fundamentally an upsert of a single
1086    /// scalar rather than a set of keys.
1087    pub fn set_drive_default_account(path: &Path, account: Option<&str>) -> Result<()> {
1088        let mut settings_value = read_or_default_settings(path)?;
1089
1090        match account {
1091            Some(name) => {
1092                let drive = ensure_object_at(&mut settings_value, &["drive"])?;
1093                drive.insert(
1094                    "default_account".to_string(),
1095                    serde_json::Value::String(name.to_string()),
1096                );
1097            }
1098            None => {
1099                if let Some(drive) = object_at_mut(&mut settings_value, &["drive"]) {
1100                    drive.remove("default_account");
1101                }
1102            }
1103        }
1104
1105        write_settings(path, &settings_value)
1106    }
1107}
1108
1109/// Navigates `root` to the env object targeted by `profile` — the base `env`
1110/// when `None`, `profiles.<name>.env` when `Some`. Thin specialization of
1111/// [`ensure_object_at`] for the two-level `env`/`profiles.<name>.env` shape.
1112fn ensure_env_object<'a>(
1113    root: &'a mut serde_json::Value,
1114    profile: Option<&str>,
1115) -> Result<&'a mut serde_json::Map<String, serde_json::Value>> {
1116    match profile {
1117        Some(name) => ensure_object_at(root, &["profiles", name, "env"]),
1118        None => ensure_object_at(root, &["env"]),
1119    }
1120}
1121
1122/// Navigates `root` to the env object targeted by `profile`, or `None` when
1123/// any node on the way is absent or not an object. Thin specialization of
1124/// [`object_at_mut`] for the two-level `env`/`profiles.<name>.env` shape.
1125fn env_object_mut<'a>(
1126    root: &'a mut serde_json::Value,
1127    profile: Option<&str>,
1128) -> Option<&'a mut serde_json::Map<String, serde_json::Value>> {
1129    match profile {
1130        Some(name) => object_at_mut(root, &["profiles", name, "env"]),
1131        None => object_at_mut(root, &["env"]),
1132    }
1133}
1134
1135/// Navigates `root` through each of `segments` in turn, creating missing
1136/// intermediate objects and replacing non-object nodes along the way, and
1137/// returns the object at the end of the path. The creating counterpart of
1138/// [`object_at_mut`], for upserts. Generalizes what were previously two
1139/// hardcoded two-level walks (`env`, `profiles.<name>.env`) to an arbitrary
1140/// depth, so a third settings substructure (`gmail.accounts.<name>`, issue
1141/// #1500) reuses the same file-handling code rather than duplicating it.
1142fn ensure_object_at<'a>(
1143    root: &'a mut serde_json::Value,
1144    segments: &[&str],
1145) -> Result<&'a mut serde_json::Map<String, serde_json::Value>> {
1146    let mut current = root;
1147    for segment in segments {
1148        if !current
1149            .get(*segment)
1150            .is_some_and(serde_json::Value::is_object)
1151        {
1152            current[*segment] = serde_json::json!({});
1153        }
1154        current = current
1155            .get_mut(*segment)
1156            .context("Internal error: target key missing immediately after being created")?;
1157    }
1158    current
1159        .as_object_mut()
1160        .context("Internal error: target key is not an object after initialization")
1161}
1162
1163/// Navigates `root` through each of `segments` in turn, or returns `None`
1164/// when any node on the way is absent or not an object. The non-creating
1165/// counterpart of [`ensure_object_at`], for removals.
1166fn object_at_mut<'a>(
1167    root: &'a mut serde_json::Value,
1168    segments: &[&str],
1169) -> Option<&'a mut serde_json::Map<String, serde_json::Value>> {
1170    let mut current = root;
1171    for segment in segments {
1172        current = current.get_mut(*segment)?;
1173    }
1174    current.as_object_mut()
1175}
1176
1177/// Reads and parses the settings file at `path` as a generic JSON value
1178/// (preserving unknown fields), or returns `{}` when the file does not exist.
1179fn read_or_default_settings(path: &Path) -> Result<serde_json::Value> {
1180    if path.exists() {
1181        let content = fs::read_to_string(path)
1182            .with_context(|| format!("Failed to read {}", path.display()))?;
1183        serde_json::from_str(&content)
1184            .with_context(|| format!("Failed to parse {}", path.display()))
1185    } else {
1186        Ok(serde_json::json!({}))
1187    }
1188}
1189
1190/// The named-account secret fields that accept a `<field>_file` companion
1191/// (#2008).
1192const ACCOUNT_SECRET_FIELDS: &[&str] = &["client_secret", "refresh_token"];
1193
1194/// The shared body of [`Settings::upsert_gmail_account`] and
1195/// [`Settings::upsert_drive_account`]: merges `vars` into
1196/// `<section>.accounts.<account>`.
1197///
1198/// A secret field ([`ACCOUNT_SECRET_FIELDS`]) whose entry already names a
1199/// `<field>_file` is written into that file instead, and the plain field is
1200/// removed, so the secret never lands in settings.json and the entry can't
1201/// hold both (#2008). Every file is checked
1202/// ([`secret_env::plan_secret_file_write`]) before any is written, and all
1203/// are written before settings.json, so a refused or failed secret write
1204/// leaves settings.json untouched.
1205///
1206/// A `client_secret_file` that holds another value is not replaced when the
1207/// incoming `client_id` differs from the entry's: that file is most likely
1208/// shared with accounts of the other OAuth client, and replacing it would
1209/// break them.
1210fn upsert_account(
1211    path: &Path,
1212    section: &str,
1213    account: &str,
1214    vars: &[(&str, serde_json::Value)],
1215) -> Result<()> {
1216    let mut settings_value = read_or_default_settings(path)?;
1217
1218    let entry = ensure_object_at(&mut settings_value, &[section, "accounts", account])?;
1219    let stored_client_id = entry
1220        .get("client_id")
1221        .and_then(serde_json::Value::as_str)
1222        .map(str::to_string);
1223    let mut secret_writes = Vec::new();
1224    for (key, value) in vars {
1225        if let Some(file) = account_secret_file(entry, key, value) {
1226            let label = format!("{section}.accounts.{account}.{key}_file");
1227            let secret = crate::utils::secret::Secret::new(value.as_str().unwrap_or_default());
1228            if let Some(write) =
1229                secret_env::plan_secret_file_write(&label, Path::new(&file), &secret)?
1230            {
1231                if *key == "client_secret" && write.replaces_existing() {
1232                    refuse_client_change(&label, stored_client_id.as_deref(), vars)?;
1233                }
1234                secret_writes.push((write, secret));
1235            } // omni-dev: coverage ignore-line reason="this closing brace reports 0 hits under llvm-cov regardless of test count — verified locally: upsert_account_writes_a_secret_into_its_file_not_settings and several other tests complete the block above (the push on the line just before, and entry.remove(*key) right after, both measure as hit), yet this specific brace, immediately after an if-let whose scrutinee ends in a `?` operator, never registers a hit; an llvm-cov region-attribution artifact, not an untested path"
1236            entry.remove(*key);
1237        } else {
1238            entry.insert((*key).to_string(), value.clone());
1239        }
1240    }
1241
1242    for (write, secret) in secret_writes {
1243        write.write(&secret)?;
1244    }
1245    write_settings(path, &settings_value)
1246}
1247
1248/// Refuses to replace the `client_secret_file` named by `label` when `vars`
1249/// carries a `client_id` other than the entry's `stored` one — see
1250/// [`upsert_account`].
1251fn refuse_client_change(
1252    label: &str,
1253    stored: Option<&str>,
1254    vars: &[(&str, serde_json::Value)],
1255) -> Result<()> {
1256    let incoming = vars
1257        .iter()
1258        .find(|(key, _)| *key == "client_id")
1259        .and_then(|(_, value)| value.as_str());
1260    if let (Some(stored), Some(incoming)) = (stored, incoming) {
1261        if stored != incoming {
1262            anyhow::bail!(
1263                "{label} holds the secret of OAuth client {stored}, not {incoming}; refusing to \
1264                 replace it, since other accounts may share it. Point {label} at a file of its \
1265                 own, or remove it to store the secret in settings.json"
1266            );
1267        }
1268    }
1269    Ok(())
1270}
1271
1272/// Resolves the secret field `<section>.accounts.<account>.<field>` from its
1273/// plain `value` or its `<field>_file` companion, with the `<NAME>_FILE`
1274/// rules (#2008); errors name the full settings key. `Ok(None)` means
1275/// neither is set.
1276///
1277/// # Errors
1278///
1279/// See [`secret_env::SecretEnvError`].
1280pub(crate) fn account_secret(
1281    section: &str,
1282    account: &str,
1283    field: &str,
1284    value: Option<&str>,
1285    file: Option<&str>,
1286) -> std::result::Result<Option<crate::utils::secret::Secret>, secret_env::SecretEnvError> {
1287    let name = format!("{section}.accounts.{account}.{field}");
1288    secret_env::resolve_secret_pair(&name, value, &format!("{name}_file"), file)
1289}
1290
1291/// Whether a named-account secret field or its `_file` companion is set
1292/// (non-empty), without reading the file — for presence-only reports. Only
1293/// the MCP `*_auth_status` tools report a named account's presence flags.
1294#[cfg(feature = "mcp")]
1295pub(crate) fn account_secret_is_set(value: Option<&str>, file: Option<&str>) -> bool {
1296    value.is_some_and(|v| !v.is_empty()) || file.is_some_and(|f| !f.is_empty())
1297}
1298
1299/// The `<key>_file` path to write `value` into instead of `entry.<key>`, when
1300/// `key` is a secret field, `value` a string, and the companion set.
1301fn account_secret_file(
1302    entry: &serde_json::Map<String, serde_json::Value>,
1303    key: &str,
1304    value: &serde_json::Value,
1305) -> Option<String> {
1306    if !ACCOUNT_SECRET_FIELDS.contains(&key) || !value.is_string() {
1307        return None;
1308    }
1309    entry
1310        .get(&format!("{key}_file"))
1311        .and_then(serde_json::Value::as_str)
1312        .filter(|file| !file.is_empty())
1313        .map(str::to_string)
1314}
1315
1316/// The single hardened write site for the settings file: creates the parent
1317/// directory `0700`, writes the pretty-printed JSON through a `0600` handle
1318/// (no window where a fresh file is world-readable), and re-tightens a
1319/// pre-existing looser-permission file on every write (issue #1128).
1320fn write_settings(path: &Path, value: &serde_json::Value) -> Result<()> {
1321    if let Some(parent) = path.parent() {
1322        if !parent.as_os_str().is_empty() {
1323            crate::daemon::paths::ensure_dir_0700(parent)?;
1324        }
1325    }
1326    let formatted =
1327        serde_json::to_string_pretty(value).context("Failed to serialize settings JSON")?;
1328    write_file_0600(path, &formatted)
1329        .with_context(|| format!("Failed to write {}", path.display()))?;
1330    crate::daemon::paths::set_file_0600(path)?;
1331    Ok(())
1332}
1333
1334/// Creates/truncates `path` with owner-only (`0600`) permissions on Unix.
1335#[cfg(unix)]
1336fn write_file_0600(path: &Path, contents: &str) -> std::io::Result<()> {
1337    use std::io::Write;
1338    use std::os::unix::fs::OpenOptionsExt;
1339
1340    let mut file = fs::OpenOptions::new()
1341        .write(true)
1342        .create(true)
1343        .truncate(true)
1344        .mode(0o600)
1345        .open(path)?;
1346    file.write_all(contents.as_bytes())
1347}
1348
1349/// Non-Unix fallback: a plain write ([`set_file_0600`](crate::daemon::paths::set_file_0600)
1350/// is a no-op there too).
1351#[cfg(not(unix))]
1352fn write_file_0600(path: &Path, contents: &str) -> std::io::Result<()> {
1353    fs::write(path, contents)
1354}
1355
1356/// Returns an environment variable with fallback to settings, honouring the
1357/// active profile from `OMNI_DEV_PROFILE`.
1358pub fn get_env_var(key: &str) -> Result<String> {
1359    get_env_var_with(&SystemEnv, Settings::load, key)
1360}
1361
1362/// Like [`get_env_var`], but also reports where the value came from.
1363///
1364/// The source is a command-line flag export, the process environment, or a
1365/// settings.json `env` map (issue #1143) — for warnings about
1366/// security-sensitive values (e.g. the claude-cli escape hatches) that
1367/// should name their source.
1368pub fn get_env_var_sourced(key: &str) -> Result<(String, EnvValueSource)> {
1369    get_env_var_sourced_with(&SystemEnv, Settings::load, exported_by_cli_flag(key), key)
1370}
1371
1372/// Pure core of [`get_env_var`]: [`get_env_var_sourced_with`] with the source
1373/// dropped.
1374fn get_env_var_with<E, F>(env: &E, load: F, key: &str) -> Result<String>
1375where
1376    E: EnvSource,
1377    F: FnOnce() -> Result<Settings>,
1378{
1379    get_env_var_sourced_with(env, load, false, key).map(|(value, _)| value)
1380}
1381
1382/// Pure core of [`get_env_var_sourced`]: `env` is the raw source, `load`
1383/// produces the settings lazily — it is invoked only on a raw-env miss,
1384/// preserving the no-disk fast path — and `from_cli_flag` says whether a flag
1385/// exported `key` this invocation (injected so tests never touch the
1386/// process-global flag registry). Tests inject a `MapEnv` and a closure
1387/// returning `Ok`/`Err` to cover both the resolved and load-failure branches
1388/// without touching disk.
1389fn get_env_var_sourced_with<E, F>(
1390    env: &E,
1391    load: F,
1392    from_cli_flag: bool,
1393    key: &str,
1394) -> Result<(String, EnvValueSource)>
1395where
1396    E: EnvSource,
1397    F: FnOnce() -> Result<Settings>,
1398{
1399    // A raw process-env hit short-circuits without loading settings from disk.
1400    // A flag export always lands in the process env, so the flag attribution
1401    // only ever applies on this branch.
1402    if let Some(value) = env.var(key) {
1403        let source = if from_cli_flag {
1404            EnvValueSource::CliFlag
1405        } else {
1406            EnvValueSource::ProcessEnv
1407        };
1408        return Ok((value, source));
1409    }
1410    match load() {
1411        Ok(settings) => settings
1412            .resolve_with_source(env, active_profile_from(env).as_deref(), key)
1413            .ok_or_else(|| anyhow::anyhow!("Environment variable not found: {key}")),
1414        Err(err) => {
1415            // If we couldn't load settings, just return the original env var error
1416            Err(anyhow::anyhow!("Environment variable not found: {key}").context(err))
1417        }
1418    }
1419}
1420
1421/// Tries multiple environment variables with fallback to settings.
1422pub fn get_env_vars(keys: &[&str]) -> Result<String> {
1423    for key in keys {
1424        if let Ok(value) = get_env_var(key) {
1425            return Ok(value);
1426        }
1427    }
1428
1429    Err(anyhow::anyhow!(
1430        "None of the environment variables found: {keys:?}"
1431    ))
1432}
1433
1434#[cfg(test)]
1435#[allow(clippy::unwrap_used, clippy::expect_used)]
1436mod tests {
1437    #[test]
1438    fn daemon_settings_are_optional_and_accept_directives() {
1439        for json in ["{}", r#"{"daemon":{}}"#] {
1440            let settings: super::Settings = serde_json::from_str(json).unwrap();
1441            assert!(settings.daemon.log_level.is_none());
1442        }
1443        let settings: super::Settings =
1444            serde_json::from_str(r#"{"daemon":{"log_level":"info,omni_dev::sessions=debug"}}"#)
1445                .unwrap();
1446        assert_eq!(
1447            settings.daemon.log_level.as_deref(),
1448            Some("info,omni_dev::sessions=debug")
1449        );
1450    }
1451
1452    use super::*;
1453    use crate::test_support::env::MapEnv;
1454    use std::env;
1455    use std::fs;
1456    use tempfile::TempDir;
1457
1458    /// Builds a `Settings` with a base `env` and one profile, for the pure
1459    /// resolver tests (no disk, no process env).
1460    fn settings_with_profile() -> Settings {
1461        let mut base = HashMap::new();
1462        base.insert("ATLASSIAN_EMAIL".to_string(), "base@x.com".to_string());
1463        base.insert("SHARED".to_string(), "base-shared".to_string());
1464
1465        let mut work_env = HashMap::new();
1466        work_env.insert("ATLASSIAN_EMAIL".to_string(), "me@work.com".to_string());
1467
1468        let mut profiles = HashMap::new();
1469        profiles.insert("work".to_string(), Profile { env: work_env });
1470
1471        Settings {
1472            env: base,
1473            profiles,
1474            ..Settings::default()
1475        }
1476    }
1477
1478    #[test]
1479    fn settings_load_from_path() {
1480        // Create a temporary directory (use current dir to avoid TMPDIR issues in tarpaulin)
1481        let temp_dir = {
1482            std::fs::create_dir_all("tmp").ok();
1483            TempDir::new_in("tmp").unwrap()
1484        };
1485        let settings_path = temp_dir.path().join("settings.json");
1486
1487        // Create a test settings file
1488        let settings_json = r#"{
1489            "env": {
1490                "TEST_VAR": "test_value",
1491                "CLAUDE_API_KEY": "test_api_key"
1492            }
1493        }"#;
1494        fs::write(&settings_path, settings_json).unwrap();
1495
1496        // Load settings
1497        let settings = Settings::load_from_path(&settings_path).unwrap();
1498
1499        // Check env vars
1500        assert_eq!(settings.env.get("TEST_VAR").unwrap(), "test_value");
1501        assert_eq!(settings.env.get("CLAUDE_API_KEY").unwrap(), "test_api_key");
1502    }
1503
1504    #[test]
1505    fn load_warn_dedup_warns_once_per_distinct_failure() {
1506        let dedup = LoadWarnDedup::new();
1507        assert!(dedup.observe(Some("broken A")));
1508        assert!(!dedup.observe(Some("broken A")), "a repeat is silent");
1509        assert!(dedup.observe(Some("broken B")), "a new failure warns");
1510        assert!(!dedup.observe(Some("broken B")));
1511    }
1512
1513    #[test]
1514    fn load_warn_dedup_rewarns_after_a_successful_load() {
1515        // A long-lived process must warn again if the file is fixed and then
1516        // broken again, even with an identical error message.
1517        let dedup = LoadWarnDedup::new();
1518        assert!(dedup.observe(Some("broken")));
1519        assert!(!dedup.observe(None), "a success never warns");
1520        assert!(dedup.observe(Some("broken")));
1521    }
1522
1523    #[test]
1524    fn load_or_warn_default_warns_and_falls_back_when_settings_json_fails_to_parse() {
1525        // The shared loader every non-`Result` call site now uses (issue
1526        // #1744) — a missing settings.json resolves to defaults with no
1527        // warning (the ordinary case); this covers "file exists but doesn't
1528        // parse", which must warn rather than silently drop configuration.
1529        let guard = crate::drive::test_support::EnvGuard::take();
1530        let dir = guard.clear_credentials();
1531        let settings_dir = dir.path().join(".omni-dev");
1532        fs::create_dir_all(&settings_dir).unwrap();
1533        fs::write(settings_dir.join("settings.json"), "{not valid json").unwrap();
1534
1535        let logs = crate::test_support::capture_at(tracing::Level::WARN, || {
1536            let settings = Settings::load_or_warn_default();
1537            assert!(settings.env.is_empty());
1538            assert!(settings.profiles.is_empty());
1539            assert!(settings.drive.accounts.is_empty());
1540        });
1541        assert!(logs.contains("settings.json"), "{logs}");
1542    }
1543
1544    #[test]
1545    fn load_daemon_reads_the_daemon_section_of_settings_json() {
1546        let guard = crate::drive::test_support::EnvGuard::take();
1547        let dir = guard.clear_credentials();
1548        let settings_dir = dir.path().join(".omni-dev");
1549        fs::create_dir_all(&settings_dir).unwrap();
1550        fs::write(
1551            settings_dir.join("settings.json"),
1552            r#"{"daemon":{"log_level":"omni_dev::sessions=debug"}}"#,
1553        )
1554        .unwrap();
1555
1556        assert_eq!(
1557            Settings::load_daemon().log_level.as_deref(),
1558            Some("omni_dev::sessions=debug")
1559        );
1560    }
1561
1562    #[test]
1563    fn load_daemon_warns_and_falls_back_when_settings_json_fails_to_parse() {
1564        let guard = crate::drive::test_support::EnvGuard::take();
1565        let dir = guard.clear_credentials();
1566        let settings_dir = dir.path().join(".omni-dev");
1567        fs::create_dir_all(&settings_dir).unwrap();
1568        fs::write(settings_dir.join("settings.json"), "{not valid json").unwrap();
1569
1570        let logs = crate::test_support::capture_at(tracing::Level::WARN, || {
1571            assert!(Settings::load_daemon().log_level.is_none());
1572        });
1573        assert!(logs.contains("settings.json"), "{logs}");
1574    }
1575
1576    #[test]
1577    fn a_settings_fallback_is_warned_about_once_per_distinct_failure() {
1578        let dedup = LoadWarnDedup::new();
1579        let logs = crate::test_support::capture_at(tracing::Level::WARN, || {
1580            warn_settings_fallback(&dedup, "broken A");
1581            warn_settings_fallback(&dedup, "broken A");
1582            warn_settings_fallback(&dedup, "broken B");
1583        });
1584        assert_eq!(logs.matches("broken A").count(), 1, "{logs}");
1585        assert_eq!(logs.matches("broken B").count(), 1, "{logs}");
1586        assert!(logs.contains("falling back to default settings"), "{logs}");
1587    }
1588
1589    #[test]
1590    fn warn_bootstrap_failure_reports_the_failure_it_is_handed() {
1591        // The bootstrap load in `main` fails before tracing exists, so it hands
1592        // its error over afterwards. Repeat suppression is asserted above on a
1593        // private dedup: doing it here would race every parallel test whose
1594        // successful load resets the process-wide one.
1595        let message = "Failed to parse settings file: /bootstrap-test/settings.json";
1596        let logs = crate::test_support::capture_at(tracing::Level::WARN, || {
1597            Settings::warn_bootstrap_failure(message);
1598        });
1599        assert!(logs.contains("/bootstrap-test/settings.json"), "{logs}");
1600    }
1601
1602    #[test]
1603    fn settings_get_env_var() {
1604        // Create a temporary directory (use current dir to avoid TMPDIR issues in tarpaulin)
1605        let temp_dir = {
1606            std::fs::create_dir_all("tmp").ok();
1607            TempDir::new_in("tmp").unwrap()
1608        };
1609        let settings_path = temp_dir.path().join("settings.json");
1610
1611        // Create a test settings file
1612        let settings_json = r#"{
1613            "env": {
1614                "TEST_VAR": "test_value",
1615                "CLAUDE_API_KEY": "test_api_key"
1616            }
1617        }"#;
1618        fs::write(&settings_path, settings_json).unwrap();
1619
1620        // Load settings
1621        let settings = Settings::load_from_path(&settings_path).unwrap();
1622
1623        // Set actual environment variable
1624        env::set_var("TEST_VAR_ENV", "env_value");
1625
1626        // Test precedence - env var should take precedence
1627        env::set_var("TEST_VAR", "env_override");
1628        assert_eq!(settings.get_env_var("TEST_VAR").unwrap(), "env_override");
1629
1630        // Test fallback to settings
1631        env::remove_var("TEST_VAR"); // Remove from environment
1632        assert_eq!(settings.get_env_var("TEST_VAR").unwrap(), "test_value");
1633
1634        // Test actual env var
1635        assert_eq!(settings.get_env_var("TEST_VAR_ENV").unwrap(), "env_value");
1636
1637        // Clean up
1638        env::remove_var("TEST_VAR_ENV");
1639    }
1640
1641    // ── profile resolution (pure: MapEnv raw env, explicit active profile) ──
1642
1643    #[test]
1644    fn resolve_no_profile_uses_base_env() {
1645        let settings = settings_with_profile();
1646        let raw = MapEnv::new();
1647        assert_eq!(
1648            settings
1649                .resolve_with(&raw, None, "ATLASSIAN_EMAIL")
1650                .as_deref(),
1651            Some("base@x.com")
1652        );
1653    }
1654
1655    #[test]
1656    fn resolve_active_profile_uses_profile_env() {
1657        let settings = settings_with_profile();
1658        let raw = MapEnv::new();
1659        assert_eq!(
1660            settings
1661                .resolve_with(&raw, Some("work"), "ATLASSIAN_EMAIL")
1662                .as_deref(),
1663            Some("me@work.com")
1664        );
1665    }
1666
1667    #[test]
1668    fn resolve_active_profile_does_not_consult_base() {
1669        // Isolated / AWS-faithful: a key present only in base is invisible while
1670        // a profile is active — fail loud rather than reuse a default token.
1671        let settings = settings_with_profile();
1672        let raw = MapEnv::new();
1673        assert_eq!(settings.resolve_with(&raw, Some("work"), "SHARED"), None);
1674    }
1675
1676    #[test]
1677    fn resolve_process_env_wins_over_profile_and_base() {
1678        let settings = settings_with_profile();
1679        let raw = MapEnv::new().with("ATLASSIAN_EMAIL", "cli@x.com");
1680        assert_eq!(
1681            settings
1682                .resolve_with(&raw, Some("work"), "ATLASSIAN_EMAIL")
1683                .as_deref(),
1684            Some("cli@x.com")
1685        );
1686        assert_eq!(
1687            settings
1688                .resolve_with(&raw, None, "ATLASSIAN_EMAIL")
1689                .as_deref(),
1690            Some("cli@x.com")
1691        );
1692    }
1693
1694    #[test]
1695    fn resolve_unknown_active_profile_yields_none() {
1696        // An unknown name never falls back to base; validation catches it at the
1697        // CLI boundary, but the resolver itself stays isolated.
1698        let settings = settings_with_profile();
1699        let raw = MapEnv::new();
1700        assert_eq!(
1701            settings.resolve_with(&raw, Some("nope"), "ATLASSIAN_EMAIL"),
1702            None
1703        );
1704    }
1705
1706    // ── pair resolution (issue #2006: NAME / NAME_FILE per layer) ──
1707
1708    #[test]
1709    fn upsert_of_a_secret_replaces_its_file_companion_in_the_same_map() {
1710        let (_tmp, path) = temp_settings_path();
1711        fs::create_dir_all(path.parent().unwrap()).unwrap();
1712        fs::write(
1713            &path,
1714            r#"{"env": {"DATADOG_API_KEY_FILE": "/run/k", "OTHER_FILE": "/keep"},
1715                "profiles": {"work": {"env": {"DATADOG_API_KEY_FILE": "/w"}}}}"#,
1716        )
1717        .unwrap();
1718
1719        Settings::upsert_env_vars_in(&path, None, &[("DATADOG_API_KEY", "v")]).unwrap();
1720
1721        let val = read_json(&path);
1722        assert_eq!(val["env"]["DATADOG_API_KEY"], "v");
1723        assert!(val["env"].get("DATADOG_API_KEY_FILE").is_none());
1724        // Non-secret keys and other maps are untouched.
1725        assert_eq!(val["env"]["OTHER_FILE"], "/keep");
1726        assert_eq!(val["profiles"]["work"]["env"]["DATADOG_API_KEY_FILE"], "/w");
1727    }
1728
1729    #[test]
1730    fn remove_of_a_secret_also_removes_its_file_companion() {
1731        let (_tmp, path) = temp_settings_path();
1732        fs::create_dir_all(path.parent().unwrap()).unwrap();
1733        fs::write(&path, r#"{"env": {"DATADOG_API_KEY_FILE": "/run/k"}}"#).unwrap();
1734
1735        assert!(Settings::remove_env_vars_in(&path, None, &["DATADOG_API_KEY"]).unwrap());
1736        assert!(read_json(&path)["env"]
1737            .get("DATADOG_API_KEY_FILE")
1738            .is_none());
1739    }
1740
1741    #[test]
1742    fn upsert_of_a_secret_refuses_when_its_command_is_set_in_the_same_map() {
1743        let (_tmp, path) = temp_settings_path();
1744        fs::create_dir_all(path.parent().unwrap()).unwrap();
1745        let before = r#"{"env": {"DATADOG_API_KEY_COMMAND": "op read op://v/dd"},
1746                "profiles": {"work": {"env": {"DATADOG_API_KEY_COMMAND": "/w"}}}}"#;
1747        fs::write(&path, before).unwrap();
1748
1749        let err = Settings::upsert_env_vars_in(
1750            &path,
1751            None,
1752            &[("DATADOG_SITE", "eu"), ("DATADOG_API_KEY", "s3cr3t-value")],
1753        )
1754        .unwrap_err()
1755        .to_string();
1756        assert!(err.contains("DATADOG_API_KEY_COMMAND"), "{err}");
1757        assert!(err.contains("nothing was saved"), "{err}");
1758        assert!(!err.contains("s3cr3t-value"), "the value leaked: {err}");
1759        assert!(!err.contains("op read"), "the command leaked: {err}");
1760        // Nothing was written, not even the non-secret key that came first.
1761        assert_eq!(fs::read_to_string(&path).unwrap(), before);
1762
1763        // The profile's map is judged on its own.
1764        let err = Settings::upsert_env_vars_in(&path, Some("work"), &[("DATADOG_API_KEY", "v")])
1765            .unwrap_err()
1766            .to_string();
1767        assert!(err.contains("profiles.work.env"), "{err}");
1768    }
1769
1770    #[test]
1771    fn upsert_ignores_an_empty_command_and_other_maps_commands() {
1772        let (_tmp, path) = temp_settings_path();
1773        fs::create_dir_all(path.parent().unwrap()).unwrap();
1774        fs::write(
1775            &path,
1776            r#"{"env": {"DATADOG_API_KEY_COMMAND": ""},
1777                "profiles": {"work": {"env": {"DATADOG_APP_KEY_COMMAND": "/w"}}}}"#,
1778        )
1779        .unwrap();
1780        Settings::upsert_env_vars_in(&path, None, &[("DATADOG_API_KEY", "v")]).unwrap();
1781        assert_eq!(read_json(&path)["env"]["DATADOG_API_KEY"], "v");
1782        // A command in another map, or for a non-secret key, never blocks.
1783        Settings::upsert_env_vars_in(&path, None, &[("DATADOG_APP_KEY", "v")]).unwrap();
1784        Settings::upsert_env_vars_in(&path, None, &[("DATADOG_SITE", "eu")]).unwrap();
1785    }
1786
1787    #[test]
1788    fn ensure_secrets_replaceable_is_the_same_check_without_writing() {
1789        let (_tmp, path) = temp_settings_path();
1790        let none = MapEnv::new();
1791        // A missing file passes.
1792        Settings::ensure_secrets_replaceable(&path, None, &["DATADOG_API_KEY"], &none).unwrap();
1793        fs::create_dir_all(path.parent().unwrap()).unwrap();
1794        fs::write(&path, r#"{"env": {"DATADOG_API_KEY_COMMAND": "/c"}}"#).unwrap();
1795        assert!(
1796            Settings::ensure_secrets_replaceable(&path, None, &["DATADOG_API_KEY"], &none).is_err()
1797        );
1798        // Another key, another map, or an absent map passes.
1799        Settings::ensure_secrets_replaceable(&path, None, &["DATADOG_APP_KEY"], &none).unwrap();
1800        Settings::ensure_secrets_replaceable(&path, Some("work"), &["DATADOG_API_KEY"], &none)
1801            .unwrap();
1802
1803        // A command exported in the process environment shadows whatever a
1804        // login would write, so it is refused even when settings.json is clean.
1805        let (_tmp2, clean) = temp_settings_path();
1806        let exported = MapEnv::new().with("DATADOG_API_KEY_COMMAND", "op read op://v/dd");
1807        let err =
1808            Settings::ensure_secrets_replaceable(&clean, None, &["DATADOG_API_KEY"], &exported)
1809                .unwrap_err()
1810                .to_string();
1811        assert!(err.contains("set in the environment"), "{err}");
1812        assert!(!err.contains("op read"), "{err}");
1813        Settings::ensure_secrets_replaceable(&clean, None, &["DATADOG_APP_KEY"], &exported)
1814            .unwrap();
1815    }
1816
1817    #[test]
1818    fn remove_of_a_secret_also_removes_its_command_companion() {
1819        let (_tmp, path) = temp_settings_path();
1820        fs::create_dir_all(path.parent().unwrap()).unwrap();
1821        fs::write(
1822            &path,
1823            r#"{"env": {"DATADOG_API_KEY_COMMAND": "/c", "OTHER_COMMAND": "/keep"}}"#,
1824        )
1825        .unwrap();
1826
1827        assert!(Settings::remove_env_vars_in(&path, None, &["DATADOG_API_KEY"]).unwrap());
1828        let val = read_json(&path);
1829        assert!(val["env"].get("DATADOG_API_KEY_COMMAND").is_none());
1830        assert_eq!(val["env"]["OTHER_COMMAND"], "/keep");
1831    }
1832
1833    fn settings_with_env(base: &[(&str, &str)], work: &[(&str, &str)]) -> Settings {
1834        let map = |pairs: &[(&str, &str)]| {
1835            pairs
1836                .iter()
1837                .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
1838                .collect::<HashMap<_, _>>()
1839        };
1840        let mut profiles = HashMap::new();
1841        profiles.insert("work".to_string(), Profile { env: map(work) });
1842        Settings {
1843            env: map(base),
1844            profiles,
1845            ..Settings::default()
1846        }
1847    }
1848
1849    #[test]
1850    fn resolve_triple_takes_the_process_env_triple_over_settings() {
1851        // `NAME_FILE` in the process env beats `NAME` in settings.json: the
1852        // layers never mix, so this is not a conflict.
1853        let settings = settings_with_env(&[("K", "from-settings")], &[]);
1854        let raw = MapEnv::new().with("K_FILE", "/run/k");
1855        assert_eq!(
1856            settings.resolve_triple_with(&raw, None, "K", "K_FILE", "K_COMMAND"),
1857            (None, Some("/run/k".to_string()), None)
1858        );
1859        // So does `NAME_COMMAND`, over both settings members.
1860        let settings = settings_with_env(&[("K", "v"), ("K_FILE", "/f")], &[]);
1861        let raw = MapEnv::new().with("K_COMMAND", "/bin/op");
1862        assert_eq!(
1863            settings.resolve_triple_with(&raw, None, "K", "K_FILE", "K_COMMAND"),
1864            (None, None, Some("/bin/op".to_string()))
1865        );
1866    }
1867
1868    #[test]
1869    fn resolve_triple_falls_back_to_one_settings_layer_as_a_triple() {
1870        let settings = settings_with_env(
1871            &[("K", "v"), ("K_FILE", "/f"), ("K_COMMAND", "/c")],
1872            &[("K_FILE", "/w")],
1873        );
1874        let raw = MapEnv::new();
1875        // All three in the base layer come back together (the resolver rejects it).
1876        assert_eq!(
1877            settings.resolve_triple_with(&raw, None, "K", "K_FILE", "K_COMMAND"),
1878            (
1879                Some("v".to_string()),
1880                Some("/f".to_string()),
1881                Some("/c".to_string())
1882            )
1883        );
1884        // A profile replaces the base layer entirely.
1885        assert_eq!(
1886            settings.resolve_triple_with(&raw, Some("work"), "K", "K_FILE", "K_COMMAND"),
1887            (None, Some("/w".to_string()), None)
1888        );
1889        assert_eq!(
1890            settings.resolve_triple_with(&raw, Some("nope"), "K", "K_FILE", "K_COMMAND"),
1891            (None, None, None)
1892        );
1893    }
1894
1895    #[test]
1896    fn resolve_triple_empty_process_env_value_neutralises_settings() {
1897        // Matches `resolve_with`: an exported `K=` shadows the settings layer.
1898        let settings = settings_with_env(&[("K", "from-settings")], &[]);
1899        let raw = MapEnv::new().with("K", "");
1900        assert_eq!(
1901            settings.resolve_triple_with(&raw, None, "K", "K_FILE", "K_COMMAND"),
1902            (Some(String::new()), None, None)
1903        );
1904    }
1905
1906    #[test]
1907    fn secret_resolver_over_settings_layers() {
1908        use crate::utils::secret_env::{secret_var, SecretEnvError};
1909        let dir = tempfile::tempdir().unwrap();
1910        let path = dir.path().join("key");
1911        std::fs::write(&path, "from-file\n").unwrap();
1912        #[cfg(unix)]
1913        {
1914            use std::os::unix::fs::PermissionsExt;
1915            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
1916        }
1917        let path = path.to_str().unwrap();
1918
1919        struct Layered<'a>(&'a Settings, MapEnv);
1920        impl EnvSource for Layered<'_> {
1921            fn var(&self, key: &str) -> Option<String> {
1922                self.0.resolve_with(&self.1, None, key)
1923            }
1924            fn var_triple(&self, key: &str, file_key: &str, command_key: &str) -> SecretTriple {
1925                self.0
1926                    .resolve_triple_with(&self.1, None, key, file_key, command_key)
1927            }
1928        }
1929
1930        // Env file overrides a settings value.
1931        let settings = settings_with_env(&[("DATADOG_API_KEY", "from-settings")], &[]);
1932        let env = Layered(&settings, MapEnv::new().with("DATADOG_API_KEY_FILE", path));
1933        let got = secret_var(&env, "DATADOG_API_KEY").unwrap().unwrap();
1934        assert_eq!(got.expose_secret(), "from-file");
1935
1936        // A settings `_FILE` works on its own.
1937        let settings = settings_with_env(&[("DATADOG_API_KEY_FILE", path)], &[]);
1938        let env = Layered(&settings, MapEnv::new());
1939        let got = secret_var(&env, "DATADOG_API_KEY").unwrap().unwrap();
1940        assert_eq!(got.expose_secret(), "from-file");
1941
1942        // Both in settings is a conflict.
1943        let settings = settings_with_env(
1944            &[("DATADOG_API_KEY", "v"), ("DATADOG_API_KEY_FILE", path)],
1945            &[],
1946        );
1947        let env = Layered(&settings, MapEnv::new());
1948        assert!(matches!(
1949            secret_var(&env, "DATADOG_API_KEY").unwrap_err(),
1950            SecretEnvError::Conflict { .. }
1951        ));
1952        // secret_var only ever reads var_triple; var itself must still resolve
1953        // through the same settings/profile chain for other EnvSource callers.
1954        assert_eq!(env.var("DATADOG_API_KEY"), Some("v".to_string()));
1955    }
1956
1957    #[test]
1958    fn settings_env_ref_var_falls_back_to_the_settings_layer() {
1959        // SettingsEnvRef::var is EnvSource's required method; secret_var and
1960        // secret_var_is_set (its only production callers) read var_triple alone,
1961        // so this exercises it directly against the same fallback chain
1962        // SettingsEnv::var uses.
1963        let settings = settings_with_env(
1964            &[("OMNI_DEV_TEST_SETTINGS_ENV_REF_K", "from-settings")],
1965            &[],
1966        );
1967        let env_ref = settings.env_source();
1968        assert_eq!(
1969            env_ref.var("OMNI_DEV_TEST_SETTINGS_ENV_REF_K"),
1970            Some("from-settings".to_string())
1971        );
1972        assert_eq!(env_ref.var("OMNI_DEV_TEST_SETTINGS_ENV_REF_MISSING"), None);
1973    }
1974
1975    // ── sourced resolution (issue #1143: provenance for warnings) ──
1976
1977    #[test]
1978    fn resolve_with_source_process_env_is_process_env() {
1979        let settings = settings_with_profile();
1980        let raw = MapEnv::new().with("ATLASSIAN_EMAIL", "cli@x.com");
1981        assert_eq!(
1982            settings.resolve_with_source(&raw, None, "ATLASSIAN_EMAIL"),
1983            Some(("cli@x.com".to_string(), EnvValueSource::ProcessEnv))
1984        );
1985    }
1986
1987    #[test]
1988    fn resolve_with_source_base_env_is_settings_env() {
1989        let settings = settings_with_profile();
1990        let raw = MapEnv::new();
1991        assert_eq!(
1992            settings.resolve_with_source(&raw, None, "ATLASSIAN_EMAIL"),
1993            Some(("base@x.com".to_string(), EnvValueSource::SettingsEnv))
1994        );
1995    }
1996
1997    #[test]
1998    fn resolve_with_source_profile_env_names_profile() {
1999        let settings = settings_with_profile();
2000        let raw = MapEnv::new();
2001        assert_eq!(
2002            settings.resolve_with_source(&raw, Some("work"), "ATLASSIAN_EMAIL"),
2003            Some((
2004                "me@work.com".to_string(),
2005                EnvValueSource::SettingsProfile("work".to_string())
2006            ))
2007        );
2008    }
2009
2010    #[test]
2011    fn resolve_with_source_missing_key_is_none() {
2012        let settings = settings_with_profile();
2013        let raw = MapEnv::new();
2014        assert_eq!(settings.resolve_with_source(&raw, None, "MISSING"), None);
2015    }
2016
2017    #[test]
2018    fn env_value_source_display_names_each_layer() {
2019        assert_eq!(EnvValueSource::CliFlag.to_string(), "command-line flag");
2020        assert_eq!(
2021            EnvValueSource::ProcessEnv.to_string(),
2022            "process environment variable (e.g. a shell export)"
2023        );
2024        assert_eq!(
2025            EnvValueSource::SettingsEnv.to_string(),
2026            "the env map in $HOME/.omni-dev/settings.json"
2027        );
2028        assert_eq!(
2029            EnvValueSource::SettingsProfile("work".to_string()).to_string(),
2030            "the profile 'work' env map in $HOME/.omni-dev/settings.json"
2031        );
2032    }
2033
2034    #[test]
2035    fn active_profile_from_reads_and_trims_empty() {
2036        assert_eq!(active_profile_from(&MapEnv::new()), None);
2037        assert_eq!(
2038            active_profile_from(&MapEnv::new().with(PROFILE_ENV_VAR, "")),
2039            None
2040        );
2041        assert_eq!(
2042            active_profile_from(&MapEnv::new().with(PROFILE_ENV_VAR, "work")).as_deref(),
2043            Some("work")
2044        );
2045    }
2046
2047    #[test]
2048    fn profile_suffix_names_profile_or_is_empty() {
2049        assert_eq!(profile_suffix(None), "");
2050        assert_eq!(profile_suffix(Some("work")), " (profile 'work')");
2051    }
2052
2053    #[test]
2054    fn validate_profile_accepts_known() {
2055        assert!(settings_with_profile().validate_profile("work").is_ok());
2056    }
2057
2058    #[test]
2059    fn validate_profile_rejects_unknown_and_lists_sorted() {
2060        let mut settings = settings_with_profile();
2061        settings
2062            .profiles
2063            .insert("personal".to_string(), Profile::default());
2064        let err = settings.validate_profile("wrok").unwrap_err().to_string();
2065        assert_eq!(
2066            err,
2067            "unknown profile 'wrok'; known profiles: personal, work"
2068        );
2069    }
2070
2071    #[test]
2072    fn validate_profile_reports_none_when_empty() {
2073        let settings = Settings::default();
2074        let err = settings.validate_profile("work").unwrap_err().to_string();
2075        assert_eq!(err, "unknown profile 'work'; known profiles: (none)");
2076    }
2077
2078    #[test]
2079    fn settings_parse_profiles_from_json() {
2080        let json = r#"{
2081            "env": { "BASE": "b" },
2082            "profiles": {
2083                "work": { "env": { "ATLASSIAN_EMAIL": "me@work.com" } }
2084            }
2085        }"#;
2086        let settings: Settings = serde_json::from_str(json).unwrap();
2087        assert_eq!(settings.env.get("BASE").unwrap(), "b");
2088        assert_eq!(
2089            settings
2090                .profiles
2091                .get("work")
2092                .unwrap()
2093                .env
2094                .get("ATLASSIAN_EMAIL")
2095                .unwrap(),
2096            "me@work.com"
2097        );
2098    }
2099
2100    #[test]
2101    fn settings_without_profiles_key_defaults_empty() {
2102        let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
2103        assert!(settings.profiles.is_empty());
2104    }
2105
2106    #[test]
2107    fn settings_parse_mcp_section_from_json() {
2108        let json = r#"{
2109            "mcp": {
2110                "default_model": "claude-sonnet-4-6",
2111                "log_level": "info",
2112                "max_response_bytes": 204800
2113            }
2114        }"#;
2115        let settings: Settings = serde_json::from_str(json).unwrap();
2116        assert_eq!(
2117            settings.mcp.default_model.as_deref(),
2118            Some("claude-sonnet-4-6")
2119        );
2120        assert_eq!(settings.mcp.log_level.as_deref(), Some("info"));
2121        assert_eq!(settings.mcp.max_response_bytes, Some(204_800));
2122    }
2123
2124    #[test]
2125    fn settings_without_mcp_key_defaults_all_none() {
2126        // An absent `mcp` block must leave every field unset so callers fall
2127        // back to the built-in defaults (byte-for-byte behaviour, issue #620).
2128        let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
2129        assert!(settings.mcp.default_model.is_none());
2130        assert!(settings.mcp.log_level.is_none());
2131        assert!(settings.mcp.max_response_bytes.is_none());
2132    }
2133
2134    #[test]
2135    fn settings_mcp_partial_section_leaves_others_none() {
2136        // A block that sets only one field leaves the rest at their defaults.
2137        let settings: Settings =
2138            serde_json::from_str(r#"{ "mcp": { "log_level": "debug" } }"#).unwrap();
2139        assert_eq!(settings.mcp.log_level.as_deref(), Some("debug"));
2140        assert!(settings.mcp.default_model.is_none());
2141        assert!(settings.mcp.max_response_bytes.is_none());
2142    }
2143
2144    #[test]
2145    fn settings_parse_gmail_section_from_json() {
2146        let json = r#"{
2147            "gmail": {
2148                "default_account": "work",
2149                "accounts": {
2150                    "work": {
2151                        "client_id": "id",
2152                        "client_secret": "secret",
2153                        "refresh_token": "token",
2154                        "scope": "https://www.googleapis.com/auth/gmail.modify",
2155                        "email_address": "alice@work.com"
2156                    }
2157                }
2158            }
2159        }"#;
2160        let settings: Settings = serde_json::from_str(json).unwrap();
2161        assert_eq!(settings.gmail.default_account.as_deref(), Some("work"));
2162        let account = settings.gmail.accounts.get("work").unwrap();
2163        assert_eq!(account.client_id.as_deref(), Some("id"));
2164        assert_eq!(account.client_secret.as_deref(), Some("secret"));
2165        assert_eq!(account.refresh_token.as_deref(), Some("token"));
2166        assert_eq!(
2167            account.scope.as_deref(),
2168            Some("https://www.googleapis.com/auth/gmail.modify")
2169        );
2170        assert_eq!(account.email_address.as_deref(), Some("alice@work.com"));
2171    }
2172
2173    #[test]
2174    fn settings_without_gmail_key_defaults_empty() {
2175        let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
2176        assert!(settings.gmail.default_account.is_none());
2177        assert!(settings.gmail.accounts.is_empty());
2178    }
2179
2180    #[test]
2181    fn settings_parse_drive_section_from_json() {
2182        let json = r#"{
2183            "drive": {
2184                "default_account": "work",
2185                "accounts": {
2186                    "work": {
2187                        "client_id": "id",
2188                        "client_secret": "secret",
2189                        "refresh_token": "token",
2190                        "scope": "https://www.googleapis.com/auth/drive.readonly",
2191                        "email_address": "alice@work.com"
2192                    }
2193                }
2194            }
2195        }"#;
2196        let settings: Settings = serde_json::from_str(json).unwrap();
2197        assert_eq!(settings.drive.default_account.as_deref(), Some("work"));
2198        let account = settings.drive.accounts.get("work").unwrap();
2199        assert_eq!(account.client_id.as_deref(), Some("id"));
2200        assert_eq!(account.client_secret.as_deref(), Some("secret"));
2201        assert_eq!(account.refresh_token.as_deref(), Some("token"));
2202        assert_eq!(
2203            account.scope.as_deref(),
2204            Some("https://www.googleapis.com/auth/drive.readonly")
2205        );
2206        assert_eq!(account.email_address.as_deref(), Some("alice@work.com"));
2207    }
2208
2209    #[test]
2210    fn settings_without_drive_key_defaults_empty() {
2211        let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
2212        assert!(settings.drive.default_account.is_none());
2213        assert!(settings.drive.accounts.is_empty());
2214    }
2215
2216    #[test]
2217    fn settings_parse_lease_section_from_json() {
2218        let json = r#"{
2219            "lease": {
2220                "default_expiry_minutes": 45,
2221                "backup_dir": "/tmp/drive-backups",
2222                "biometrics_only": true,
2223                "allow_headless": true
2224            }
2225        }"#;
2226        let settings: Settings = serde_json::from_str(json).unwrap();
2227        assert_eq!(settings.lease.default_expiry_minutes, Some(45));
2228        assert_eq!(
2229            settings.lease.backup_dir.as_deref(),
2230            Some(Path::new("/tmp/drive-backups"))
2231        );
2232        assert!(settings.lease.biometrics_only);
2233        assert!(settings.lease.allow_headless);
2234    }
2235
2236    #[test]
2237    fn settings_without_lease_key_defaults_all_unset() {
2238        // An absent `lease` block must leave every field at its built-in
2239        // default so `drive lease` behaves byte-for-byte as before (issue
2240        // #1677).
2241        let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
2242        assert!(settings.lease.default_expiry_minutes.is_none());
2243        assert!(settings.lease.backup_dir.is_none());
2244        assert!(!settings.lease.biometrics_only);
2245        assert!(!settings.lease.allow_headless);
2246    }
2247
2248    #[test]
2249    fn settings_lease_partial_section_leaves_others_default() {
2250        let settings: Settings =
2251            serde_json::from_str(r#"{ "lease": { "biometrics_only": true } }"#).unwrap();
2252        assert!(settings.lease.biometrics_only);
2253        assert!(settings.lease.default_expiry_minutes.is_none());
2254        assert!(settings.lease.backup_dir.is_none());
2255        assert!(!settings.lease.allow_headless);
2256    }
2257
2258    // ── free get_env_var seam (pure: injected raw env + lazy settings loader) ──
2259
2260    #[test]
2261    fn get_env_var_with_returns_raw_hit_without_loading() {
2262        let env = MapEnv::new().with("K", "v");
2263        let value = get_env_var_with(&env, || panic!("must not load settings"), "K").unwrap();
2264        assert_eq!(value, "v");
2265    }
2266
2267    #[test]
2268    fn get_env_var_with_falls_back_to_base_settings() {
2269        let settings = settings_with_profile();
2270        let env = MapEnv::new();
2271        let value = get_env_var_with(&env, || Ok(settings), "ATLASSIAN_EMAIL").unwrap();
2272        assert_eq!(value, "base@x.com");
2273    }
2274
2275    #[test]
2276    fn get_env_var_with_honours_active_profile() {
2277        let settings = settings_with_profile();
2278        let env = MapEnv::new().with(PROFILE_ENV_VAR, "work");
2279        let value = get_env_var_with(&env, || Ok(settings), "ATLASSIAN_EMAIL").unwrap();
2280        assert_eq!(value, "me@work.com");
2281    }
2282
2283    #[test]
2284    fn get_env_var_with_missing_key_is_not_found() {
2285        let env = MapEnv::new();
2286        let err = get_env_var_with(&env, || Ok(Settings::default()), "MISSING")
2287            .unwrap_err()
2288            .to_string();
2289        assert!(err.contains("Environment variable not found: MISSING"));
2290    }
2291
2292    #[test]
2293    fn get_env_var_with_load_error_maps_to_not_found() {
2294        let env = MapEnv::new();
2295        let err =
2296            get_env_var_with(&env, || Err(anyhow::anyhow!("disk boom")), "MISSING").unwrap_err();
2297        // The load failure is the top-level context; the not-found error is its
2298        // source. The full chain (`{:#}`) carries both.
2299        assert_eq!(err.to_string(), "disk boom");
2300        let chain = format!("{err:#}");
2301        assert!(chain.contains("Environment variable not found: MISSING"));
2302    }
2303
2304    // ── sourced get_env_var seam (issue #1143) ──
2305
2306    #[test]
2307    fn get_env_var_sourced_with_raw_hit_is_process_env() {
2308        let env = MapEnv::new().with("K", "v");
2309        let resolved =
2310            get_env_var_sourced_with(&env, || panic!("must not load settings"), false, "K")
2311                .unwrap();
2312        assert_eq!(resolved, ("v".to_string(), EnvValueSource::ProcessEnv));
2313    }
2314
2315    #[test]
2316    fn get_env_var_sourced_with_flag_export_is_cli_flag() {
2317        let env = MapEnv::new().with("K", "true");
2318        let resolved =
2319            get_env_var_sourced_with(&env, || panic!("must not load settings"), true, "K").unwrap();
2320        assert_eq!(resolved, ("true".to_string(), EnvValueSource::CliFlag));
2321    }
2322
2323    #[test]
2324    fn get_env_var_sourced_with_falls_back_to_settings_sources() {
2325        let settings = settings_with_profile();
2326        let env = MapEnv::new();
2327        let resolved =
2328            get_env_var_sourced_with(&env, || Ok(settings), false, "ATLASSIAN_EMAIL").unwrap();
2329        assert_eq!(
2330            resolved,
2331            ("base@x.com".to_string(), EnvValueSource::SettingsEnv)
2332        );
2333
2334        let settings = settings_with_profile();
2335        let env = MapEnv::new().with(PROFILE_ENV_VAR, "work");
2336        let resolved =
2337            get_env_var_sourced_with(&env, || Ok(settings), false, "ATLASSIAN_EMAIL").unwrap();
2338        assert_eq!(
2339            resolved,
2340            (
2341                "me@work.com".to_string(),
2342                EnvValueSource::SettingsProfile("work".to_string())
2343            )
2344        );
2345    }
2346
2347    #[test]
2348    fn cli_flag_export_registry_roundtrip() {
2349        // Unique key: the registry is a process-global, additive-only set, so
2350        // this test must not share keys with other tests (or production code).
2351        const KEY: &str = "OMNI_DEV_TEST_1143_REGISTRY_ROUNDTRIP";
2352        assert!(!exported_by_cli_flag(KEY));
2353        note_cli_flag_export(KEY);
2354        assert!(exported_by_cli_flag(KEY));
2355    }
2356
2357    // ── env-write helpers (injected paths, no HOME mutation — issue #1030) ──
2358
2359    /// Creates a tempdir under `tmp/` (avoids TMPDIR issues in tarpaulin) and
2360    /// returns it with a `<dir>/.omni-dev/settings.json` path inside it.
2361    fn temp_settings_path() -> (TempDir, std::path::PathBuf) {
2362        let temp_dir = {
2363            std::fs::create_dir_all("tmp").ok();
2364            TempDir::new_in("tmp").unwrap()
2365        };
2366        let path = temp_dir.path().join(".omni-dev").join("settings.json");
2367        (temp_dir, path)
2368    }
2369
2370    fn read_json(path: &Path) -> serde_json::Value {
2371        serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap()
2372    }
2373
2374    #[test]
2375    fn upsert_env_vars_creates_file_and_dir_with_secure_permissions() {
2376        let (_tmp, path) = temp_settings_path();
2377
2378        Settings::upsert_env_vars(&path, &[("A_KEY", "a"), ("B_KEY", "b")]).unwrap();
2379
2380        let val = read_json(&path);
2381        assert_eq!(val["env"]["A_KEY"], "a");
2382        assert_eq!(val["env"]["B_KEY"], "b");
2383
2384        // Credential store hardening (issue #1128): dir 0700, file 0600.
2385        #[cfg(unix)]
2386        {
2387            use std::os::unix::fs::PermissionsExt;
2388            let dir_mode = fs::metadata(path.parent().unwrap())
2389                .unwrap()
2390                .permissions()
2391                .mode();
2392            assert_eq!(dir_mode & 0o777, 0o700);
2393            let file_mode = fs::metadata(&path).unwrap().permissions().mode();
2394            assert_eq!(file_mode & 0o777, 0o600);
2395        }
2396    }
2397
2398    #[test]
2399    fn upsert_env_vars_merges_and_preserves_unknown_fields() {
2400        let (_tmp, path) = temp_settings_path();
2401        fs::create_dir_all(path.parent().unwrap()).unwrap();
2402        fs::write(&path, r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#).unwrap();
2403
2404        Settings::upsert_env_vars(&path, &[("A_KEY", "new")]).unwrap();
2405
2406        let val = read_json(&path);
2407        assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
2408        assert_eq!(val["extra"], true);
2409        assert_eq!(val["env"]["A_KEY"], "new");
2410    }
2411
2412    #[test]
2413    fn upsert_env_vars_replaces_non_object_env() {
2414        let (_tmp, path) = temp_settings_path();
2415        fs::create_dir_all(path.parent().unwrap()).unwrap();
2416        fs::write(&path, r#"{"env": "not-an-object"}"#).unwrap();
2417
2418        Settings::upsert_env_vars(&path, &[("A_KEY", "a")]).unwrap();
2419
2420        assert_eq!(read_json(&path)["env"]["A_KEY"], "a");
2421    }
2422
2423    #[cfg(unix)]
2424    #[test]
2425    fn upsert_env_vars_retightens_loose_permissions() {
2426        use std::os::unix::fs::PermissionsExt;
2427
2428        let (_tmp, path) = temp_settings_path();
2429        fs::create_dir_all(path.parent().unwrap()).unwrap();
2430        fs::write(&path, r#"{"env": {}}"#).unwrap();
2431        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
2432
2433        Settings::upsert_env_vars(&path, &[("A_KEY", "a")]).unwrap();
2434
2435        let file_mode = fs::metadata(&path).unwrap().permissions().mode();
2436        assert_eq!(file_mode & 0o777, 0o600);
2437    }
2438
2439    #[test]
2440    fn remove_env_vars_removes_listed_keys_and_preserves_rest() {
2441        let (_tmp, path) = temp_settings_path();
2442        fs::create_dir_all(path.parent().unwrap()).unwrap();
2443        fs::write(
2444            &path,
2445            r#"{"env": {"A_KEY": "a", "B_KEY": "b", "OTHER_KEY": "keep"}, "extra": true}"#,
2446        )
2447        .unwrap();
2448
2449        let removed = Settings::remove_env_vars(&path, &["A_KEY", "B_KEY", "ABSENT"]).unwrap();
2450        assert!(removed);
2451
2452        let val = read_json(&path);
2453        assert!(val["env"].get("A_KEY").is_none());
2454        assert!(val["env"].get("B_KEY").is_none());
2455        assert_eq!(val["env"]["OTHER_KEY"], "keep");
2456        assert_eq!(val["extra"], true);
2457    }
2458
2459    #[test]
2460    fn remove_env_vars_false_when_file_missing() {
2461        let (_tmp, path) = temp_settings_path();
2462        assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
2463        assert!(!path.exists());
2464    }
2465
2466    #[test]
2467    fn remove_env_vars_false_when_env_missing_or_not_an_object() {
2468        let (_tmp, path) = temp_settings_path();
2469        fs::create_dir_all(path.parent().unwrap()).unwrap();
2470
2471        // No "env" key at all.
2472        fs::write(&path, r#"{"extra": true}"#).unwrap();
2473        assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
2474
2475        // "env" present but not an object.
2476        fs::write(&path, r#"{"env": "not-an-object"}"#).unwrap();
2477        assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
2478    }
2479
2480    #[test]
2481    fn upsert_env_vars_bare_filename_skips_dir_creation() {
2482        // A bare relative filename has an empty parent — the dir-creation
2483        // branch must be skipped, not fail on `create_dir_all("")`.
2484        let name = format!("tmp-upsert-bare-{}.json", std::process::id());
2485        let path = Path::new(&name);
2486
2487        Settings::upsert_env_vars(path, &[("A_KEY", "a")]).unwrap();
2488
2489        assert_eq!(read_json(path)["env"]["A_KEY"], "a");
2490        fs::remove_file(path).unwrap();
2491    }
2492
2493    #[test]
2494    fn remove_env_vars_false_when_keys_absent_leaves_file_untouched() {
2495        let (_tmp, path) = temp_settings_path();
2496        fs::create_dir_all(path.parent().unwrap()).unwrap();
2497        let original = r#"{"env": {"OTHER_KEY": "keep"}}"#;
2498        fs::write(&path, original).unwrap();
2499
2500        let removed = Settings::remove_env_vars(&path, &["A_KEY"]).unwrap();
2501        assert!(!removed);
2502        // Not rewritten: the raw bytes are exactly as written.
2503        assert_eq!(fs::read_to_string(&path).unwrap(), original);
2504    }
2505
2506    // ── profile-targeted env writes (issue #1116) ────────────────────
2507
2508    #[test]
2509    fn upsert_env_vars_in_profile_creates_profile_env() {
2510        let (_tmp, path) = temp_settings_path();
2511
2512        Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
2513
2514        let val = read_json(&path);
2515        assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "a");
2516        // The base env map is not touched (read-side isolation mirrored).
2517        assert!(val.get("env").is_none());
2518
2519        // Credential store hardening (issue #1128) applies to profile
2520        // writes too: dir 0700, file 0600.
2521        #[cfg(unix)]
2522        {
2523            use std::os::unix::fs::PermissionsExt;
2524            let dir_mode = fs::metadata(path.parent().unwrap())
2525                .unwrap()
2526                .permissions()
2527                .mode();
2528            assert_eq!(dir_mode & 0o777, 0o700);
2529            let file_mode = fs::metadata(&path).unwrap().permissions().mode();
2530            assert_eq!(file_mode & 0o777, 0o600);
2531        }
2532    }
2533
2534    #[test]
2535    fn upsert_env_vars_in_profile_preserves_base_and_other_profiles() {
2536        let (_tmp, path) = temp_settings_path();
2537        fs::create_dir_all(path.parent().unwrap()).unwrap();
2538        fs::write(
2539            &path,
2540            r#"{
2541                "env": {"SHARED": "base"},
2542                "profiles": {
2543                    "work": {"env": {"OLD": "keep"}},
2544                    "home": {"env": {"SHARED": "home"}}
2545                },
2546                "extra": true
2547            }"#,
2548        )
2549        .unwrap();
2550
2551        Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
2552
2553        let val = read_json(&path);
2554        assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "a");
2555        assert_eq!(val["profiles"]["work"]["env"]["OLD"], "keep");
2556        assert_eq!(val["profiles"]["home"]["env"]["SHARED"], "home");
2557        assert_eq!(val["env"]["SHARED"], "base");
2558        assert_eq!(val["extra"], true);
2559    }
2560
2561    #[test]
2562    fn upsert_env_vars_in_profile_replaces_non_object_nodes() {
2563        let (_tmp, path) = temp_settings_path();
2564        fs::create_dir_all(path.parent().unwrap()).unwrap();
2565
2566        // "profiles" itself is not an object.
2567        fs::write(&path, r#"{"profiles": "bogus"}"#).unwrap();
2568        Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
2569        assert_eq!(read_json(&path)["profiles"]["work"]["env"]["A_KEY"], "a");
2570
2571        // The profile node is not an object.
2572        fs::write(&path, r#"{"profiles": {"work": []}}"#).unwrap();
2573        Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
2574        assert_eq!(read_json(&path)["profiles"]["work"]["env"]["A_KEY"], "a");
2575    }
2576
2577    #[test]
2578    fn remove_env_vars_in_profile_removes_only_profile_keys() {
2579        let (_tmp, path) = temp_settings_path();
2580        fs::create_dir_all(path.parent().unwrap()).unwrap();
2581        fs::write(
2582            &path,
2583            r#"{
2584                "env": {"A_KEY": "base"},
2585                "profiles": {"work": {"env": {"A_KEY": "work", "OTHER": "keep"}}}
2586            }"#,
2587        )
2588        .unwrap();
2589
2590        let removed = Settings::remove_env_vars_in(&path, Some("work"), &["A_KEY"]).unwrap();
2591        assert!(removed);
2592
2593        let val = read_json(&path);
2594        assert!(val["profiles"]["work"]["env"].get("A_KEY").is_none());
2595        assert_eq!(val["profiles"]["work"]["env"]["OTHER"], "keep");
2596        // The base copy of the same key survives.
2597        assert_eq!(val["env"]["A_KEY"], "base");
2598    }
2599
2600    #[test]
2601    fn remove_env_vars_in_profile_false_when_profile_missing() {
2602        let (_tmp, path) = temp_settings_path();
2603        fs::create_dir_all(path.parent().unwrap()).unwrap();
2604        let original = r#"{"env": {"A_KEY": "base"}}"#;
2605        fs::write(&path, original).unwrap();
2606
2607        let removed = Settings::remove_env_vars_in(&path, Some("work"), &["A_KEY"]).unwrap();
2608        assert!(!removed);
2609        // Not rewritten: the raw bytes are exactly as written.
2610        assert_eq!(fs::read_to_string(&path).unwrap(), original);
2611    }
2612
2613    #[test]
2614    fn remove_env_vars_in_none_targets_base_env() {
2615        let (_tmp, path) = temp_settings_path();
2616        fs::create_dir_all(path.parent().unwrap()).unwrap();
2617        fs::write(
2618            &path,
2619            r#"{"env": {"A_KEY": "base"}, "profiles": {"work": {"env": {"A_KEY": "work"}}}}"#,
2620        )
2621        .unwrap();
2622
2623        let removed = Settings::remove_env_vars_in(&path, None, &["A_KEY"]).unwrap();
2624        assert!(removed);
2625
2626        let val = read_json(&path);
2627        assert!(val["env"].get("A_KEY").is_none());
2628        assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "work");
2629    }
2630
2631    // ── generalized path-segment walkers (issue #1500) ────────────────
2632
2633    #[test]
2634    fn ensure_object_at_creates_nested_path_at_arbitrary_depth() {
2635        let mut root = serde_json::json!({});
2636        {
2637            let map = ensure_object_at(&mut root, &["gmail", "accounts", "work"]).unwrap();
2638            map.insert("client_id".to_string(), serde_json::json!("id"));
2639        }
2640        assert_eq!(root["gmail"]["accounts"]["work"]["client_id"], "id");
2641    }
2642
2643    #[test]
2644    fn ensure_object_at_replaces_non_object_nodes_along_path() {
2645        let mut root = serde_json::json!({"gmail": "bogus"});
2646        {
2647            let map = ensure_object_at(&mut root, &["gmail", "accounts", "work"]).unwrap();
2648            map.insert("client_id".to_string(), serde_json::json!("id"));
2649        }
2650        assert_eq!(root["gmail"]["accounts"]["work"]["client_id"], "id");
2651    }
2652
2653    #[test]
2654    fn object_at_mut_none_when_any_segment_absent() {
2655        let mut root = serde_json::json!({"gmail": {"accounts": {}}});
2656        assert!(object_at_mut(&mut root, &["gmail", "accounts", "work"]).is_none());
2657        assert!(object_at_mut(&mut root, &["missing", "accounts"]).is_none());
2658    }
2659
2660    // ── gmail account writes (issue #1500) ─────────────────────────────
2661
2662    #[test]
2663    fn upsert_gmail_account_creates_nested_path_and_preserves_siblings() {
2664        let (_tmp, path) = temp_settings_path();
2665        fs::create_dir_all(path.parent().unwrap()).unwrap();
2666        fs::write(
2667            &path,
2668            r#"{"env": {"SHARED": "base"}, "gmail": {"accounts": {"personal": {"client_id": "keep"}}}, "extra": true}"#,
2669        )
2670        .unwrap();
2671
2672        Settings::upsert_gmail_account(
2673            &path,
2674            "work",
2675            &[
2676                ("client_id", serde_json::Value::String("id".to_string())),
2677                (
2678                    "refresh_token",
2679                    serde_json::Value::String("token".to_string()),
2680                ),
2681            ],
2682        )
2683        .unwrap();
2684
2685        let val = read_json(&path);
2686        assert_eq!(val["gmail"]["accounts"]["work"]["client_id"], "id");
2687        assert_eq!(val["gmail"]["accounts"]["work"]["refresh_token"], "token");
2688        assert_eq!(val["gmail"]["accounts"]["personal"]["client_id"], "keep");
2689        assert_eq!(val["env"]["SHARED"], "base");
2690        assert_eq!(val["extra"], true);
2691
2692        #[cfg(unix)]
2693        {
2694            use std::os::unix::fs::PermissionsExt;
2695            let file_mode = fs::metadata(&path).unwrap().permissions().mode();
2696            assert_eq!(file_mode & 0o777, 0o600);
2697        }
2698    }
2699
2700    /// Regression test for the PR #1528 review comment: writing a non-string
2701    /// field (e.g. `chrome_profile_from_email: bool`) through
2702    /// `upsert_gmail_account` must round-trip through `Settings::load()` —
2703    /// before the `vars` type was widened to `serde_json::Value`, this
2704    /// silently wrote the JSON string `"true"` into a `bool` field and broke
2705    /// parsing of the entire settings file on next load.
2706    #[test]
2707    fn upsert_gmail_account_writes_a_bool_value_that_round_trips_through_settings_load() {
2708        let (_tmp, path) = temp_settings_path();
2709
2710        Settings::upsert_gmail_account(
2711            &path,
2712            "work",
2713            &[("chrome_profile_from_email", serde_json::Value::Bool(true))],
2714        )
2715        .unwrap();
2716
2717        let val = read_json(&path);
2718        assert_eq!(
2719            val["gmail"]["accounts"]["work"]["chrome_profile_from_email"],
2720            true
2721        );
2722
2723        let settings = Settings::load_from_path(&path).unwrap();
2724        assert!(
2725            settings.gmail.accounts["work"].chrome_profile_from_email,
2726            "the bool field must deserialize back to `true`, not the string \"true\""
2727        );
2728    }
2729
2730    #[test]
2731    fn remove_gmail_account_true_when_present_false_when_absent() {
2732        let (_tmp, path) = temp_settings_path();
2733        fs::create_dir_all(path.parent().unwrap()).unwrap();
2734        fs::write(
2735            &path,
2736            r#"{"gmail": {"accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
2737        )
2738        .unwrap();
2739
2740        assert!(Settings::remove_gmail_account(&path, "work").unwrap());
2741        let val = read_json(&path);
2742        assert!(val["gmail"]["accounts"].get("work").is_none());
2743        assert_eq!(val["gmail"]["accounts"]["personal"]["client_id"], "keep");
2744
2745        assert!(!Settings::remove_gmail_account(&path, "work").unwrap());
2746    }
2747
2748    #[test]
2749    fn remove_gmail_account_clears_default_account_when_it_named_the_removed_account() {
2750        let (_tmp, path) = temp_settings_path();
2751        fs::create_dir_all(path.parent().unwrap()).unwrap();
2752        fs::write(
2753            &path,
2754            r#"{"gmail": {"default_account": "work", "accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
2755        )
2756        .unwrap();
2757
2758        assert!(Settings::remove_gmail_account(&path, "work").unwrap());
2759        let val = read_json(&path);
2760        assert!(val["gmail"].get("default_account").is_none());
2761        assert_eq!(val["gmail"]["accounts"]["personal"]["client_id"], "keep");
2762    }
2763
2764    #[test]
2765    fn remove_gmail_account_leaves_default_account_untouched_when_it_names_a_different_account() {
2766        let (_tmp, path) = temp_settings_path();
2767        fs::create_dir_all(path.parent().unwrap()).unwrap();
2768        fs::write(
2769            &path,
2770            r#"{"gmail": {"default_account": "personal", "accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
2771        )
2772        .unwrap();
2773
2774        assert!(Settings::remove_gmail_account(&path, "work").unwrap());
2775        let val = read_json(&path);
2776        assert_eq!(val["gmail"]["default_account"], "personal");
2777    }
2778
2779    #[test]
2780    fn remove_gmail_account_false_when_file_missing() {
2781        let (_tmp, path) = temp_settings_path();
2782        assert!(!Settings::remove_gmail_account(&path, "work").unwrap());
2783        assert!(!path.exists());
2784    }
2785
2786    #[test]
2787    fn set_gmail_default_account_sets_and_clears() {
2788        let (_tmp, path) = temp_settings_path();
2789
2790        Settings::set_gmail_default_account(&path, Some("work")).unwrap();
2791        assert_eq!(read_json(&path)["gmail"]["default_account"], "work");
2792
2793        Settings::set_gmail_default_account(&path, None).unwrap();
2794        assert!(read_json(&path)["gmail"].get("default_account").is_none());
2795    }
2796
2797    // ── drive account writes (issue #1522) ─────────────────────────────
2798
2799    #[test]
2800    fn upsert_drive_account_creates_nested_path_and_preserves_siblings() {
2801        let (_tmp, path) = temp_settings_path();
2802        fs::create_dir_all(path.parent().unwrap()).unwrap();
2803        fs::write(
2804            &path,
2805            r#"{"env": {"SHARED": "base"}, "drive": {"accounts": {"personal": {"client_id": "keep"}}}, "extra": true}"#,
2806        )
2807        .unwrap();
2808
2809        Settings::upsert_drive_account(
2810            &path,
2811            "work",
2812            &[
2813                ("client_id", serde_json::Value::String("id".to_string())),
2814                (
2815                    "refresh_token",
2816                    serde_json::Value::String("token".to_string()),
2817                ),
2818            ],
2819        )
2820        .unwrap();
2821
2822        let val = read_json(&path);
2823        assert_eq!(val["drive"]["accounts"]["work"]["client_id"], "id");
2824        assert_eq!(val["drive"]["accounts"]["work"]["refresh_token"], "token");
2825        assert_eq!(val["drive"]["accounts"]["personal"]["client_id"], "keep");
2826        assert_eq!(val["env"]["SHARED"], "base");
2827        assert_eq!(val["extra"], true);
2828
2829        #[cfg(unix)]
2830        {
2831            use std::os::unix::fs::PermissionsExt;
2832            let file_mode = fs::metadata(&path).unwrap().permissions().mode();
2833            assert_eq!(file_mode & 0o777, 0o600);
2834        }
2835    }
2836
2837    // ── named-account `_file` secrets (#2008) ────────────────────────────
2838
2839    /// A settings file whose `drive.accounts.work` names `refresh_token_file`
2840    /// (and optionally `client_secret_file`) under `dir`.
2841    fn settings_with_secret_files(
2842        path: &Path,
2843        client_secret_file: Option<&Path>,
2844        refresh_token_file: &Path,
2845    ) {
2846        let mut work = serde_json::json!({
2847            "client_id": "id",
2848            "refresh_token_file": refresh_token_file.to_str().unwrap(),
2849        });
2850        if let Some(file) = client_secret_file {
2851            work["client_secret_file"] = serde_json::json!(file.to_str().unwrap());
2852        }
2853        fs::create_dir_all(path.parent().unwrap()).unwrap();
2854        fs::write(
2855            path,
2856            serde_json::json!({ "drive": { "accounts": { "work": work } } }).to_string(),
2857        )
2858        .unwrap();
2859    }
2860
2861    #[test]
2862    fn account_secret_file_fields_deserialize() {
2863        let (_tmp, path) = temp_settings_path();
2864        settings_with_secret_files(&path, Some(Path::new("/s/cs")), Path::new("/s/rt"));
2865        let settings = Settings::load_from_path(&path).unwrap();
2866        let work = &settings.drive.accounts["work"];
2867        assert_eq!(work.client_secret_file.as_deref(), Some("/s/cs"));
2868        assert_eq!(work.refresh_token_file.as_deref(), Some("/s/rt"));
2869        assert!(work.client_secret.is_none());
2870    }
2871
2872    #[test]
2873    fn upsert_account_writes_a_secret_into_its_file_not_settings() {
2874        let (tmp, path) = temp_settings_path();
2875        let token_file = tmp.path().join("refresh-token");
2876        settings_with_secret_files(&path, None, &token_file);
2877
2878        Settings::upsert_drive_account(
2879            &path,
2880            "work",
2881            &[
2882                ("client_secret", serde_json::json!("plain-secret")),
2883                ("refresh_token", serde_json::json!("new-token")),
2884                ("scope", serde_json::json!("s")),
2885            ],
2886        )
2887        .unwrap();
2888
2889        let raw = fs::read_to_string(&path).unwrap();
2890        assert!(!raw.contains("new-token"), "{raw}");
2891        let val = read_json(&path);
2892        let work = &val["drive"]["accounts"]["work"];
2893        assert!(work.get("refresh_token").is_none());
2894        assert_eq!(work["refresh_token_file"], token_file.to_str().unwrap());
2895        // A secret field without a companion is written as before.
2896        assert_eq!(work["client_secret"], "plain-secret");
2897        assert_eq!(work["scope"], "s");
2898        assert_eq!(fs::read_to_string(&token_file).unwrap(), "new-token\n");
2899    }
2900
2901    #[test]
2902    fn upsert_account_drops_a_plain_field_that_would_conflict_with_its_file() {
2903        let (tmp, path) = temp_settings_path();
2904        let secret_file = tmp.path().join("client-secret");
2905        fs::create_dir_all(path.parent().unwrap()).unwrap();
2906        fs::write(
2907            &path,
2908            serde_json::json!({ "gmail": { "accounts": { "work": {
2909                "client_secret": "stale",
2910                "client_secret_file": secret_file.to_str().unwrap(),
2911            }}}})
2912            .to_string(),
2913        )
2914        .unwrap();
2915
2916        Settings::upsert_gmail_account(
2917            &path,
2918            "work",
2919            &[("client_secret", serde_json::json!("fresh"))],
2920        )
2921        .unwrap();
2922
2923        let val = read_json(&path);
2924        assert!(val["gmail"]["accounts"]["work"]
2925            .get("client_secret")
2926            .is_none());
2927        assert_eq!(fs::read_to_string(&secret_file).unwrap(), "fresh\n");
2928    }
2929
2930    #[test]
2931    fn upsert_account_leaves_settings_untouched_when_the_secret_write_fails() {
2932        let (tmp, path) = temp_settings_path();
2933        let token_file = tmp.path().join("missing-dir").join("refresh-token");
2934        settings_with_secret_files(&path, None, &token_file);
2935        let before = fs::read_to_string(&path).unwrap();
2936
2937        let err = Settings::upsert_drive_account(
2938            &path,
2939            "work",
2940            &[
2941                ("scope", serde_json::json!("s")),
2942                ("refresh_token", serde_json::json!("new-token")),
2943            ],
2944        )
2945        .unwrap_err();
2946
2947        assert!(
2948            err.to_string()
2949                .contains("drive.accounts.work.refresh_token_file"),
2950            "{err}"
2951        );
2952        assert!(!err.to_string().contains("new-token"), "{err}");
2953        assert_eq!(fs::read_to_string(&path).unwrap(), before);
2954    }
2955
2956    /// Writes `contents` owner-only to `name` under `dir`.
2957    fn owner_only(dir: &Path, name: &str, contents: &str) -> std::path::PathBuf {
2958        let file = dir.join(name);
2959        fs::write(&file, contents).unwrap();
2960        #[cfg(unix)]
2961        {
2962            use std::os::unix::fs::PermissionsExt;
2963            fs::set_permissions(&file, fs::Permissions::from_mode(0o600)).unwrap();
2964        }
2965        file
2966    }
2967
2968    #[test]
2969    fn upsert_account_checks_every_secret_file_before_writing_any() {
2970        let (tmp, path) = temp_settings_path();
2971        let secret_file = owner_only(tmp.path(), "client-secret", "old-secret\n");
2972        let token_file = tmp.path().join("missing-dir").join("refresh-token");
2973        settings_with_secret_files(&path, Some(&secret_file), &token_file);
2974
2975        Settings::upsert_drive_account(
2976            &path,
2977            "work",
2978            &[
2979                ("client_secret", serde_json::json!("new-secret")),
2980                ("refresh_token", serde_json::json!("new-token")),
2981            ],
2982        )
2983        .unwrap_err();
2984
2985        assert_eq!(fs::read_to_string(&secret_file).unwrap(), "old-secret\n");
2986    }
2987
2988    #[test]
2989    fn upsert_account_refuses_to_replace_a_client_secret_file_of_another_client() {
2990        let (tmp, path) = temp_settings_path();
2991        let secret_file = owner_only(tmp.path(), "client-secret", "old-secret\n");
2992        let token_file = tmp.path().join("refresh-token");
2993        settings_with_secret_files(&path, Some(&secret_file), &token_file);
2994        let before = fs::read_to_string(&path).unwrap();
2995
2996        let err = Settings::upsert_drive_account(
2997            &path,
2998            "work",
2999            &[
3000                ("client_id", serde_json::json!("other-id")),
3001                ("client_secret", serde_json::json!("other-secret")),
3002                ("refresh_token", serde_json::json!("new-token")),
3003            ],
3004        )
3005        .unwrap_err();
3006
3007        let message = err.to_string();
3008        assert!(
3009            message.contains("drive.accounts.work.client_secret_file"),
3010            "{message}"
3011        );
3012        assert!(message.contains("other-id"), "{message}");
3013        assert!(!message.contains("other-secret"), "{message}");
3014        assert_eq!(fs::read_to_string(&secret_file).unwrap(), "old-secret\n");
3015        assert!(!token_file.exists());
3016        assert_eq!(fs::read_to_string(&path).unwrap(), before);
3017    }
3018
3019    #[test]
3020    fn upsert_account_replaces_a_client_secret_file_for_the_same_client() {
3021        let (tmp, path) = temp_settings_path();
3022        let secret_file = owner_only(tmp.path(), "client-secret", "old-secret\n");
3023        let token_file = tmp.path().join("refresh-token");
3024        settings_with_secret_files(&path, Some(&secret_file), &token_file);
3025
3026        Settings::upsert_drive_account(
3027            &path,
3028            "work",
3029            &[
3030                ("client_id", serde_json::json!("id")),
3031                ("client_secret", serde_json::json!("rotated-secret")),
3032                ("refresh_token", serde_json::json!("new-token")),
3033            ],
3034        )
3035        .unwrap();
3036
3037        assert_eq!(
3038            fs::read_to_string(&secret_file).unwrap(),
3039            "rotated-secret\n"
3040        );
3041        assert_eq!(fs::read_to_string(&token_file).unwrap(), "new-token\n");
3042    }
3043
3044    #[test]
3045    fn upsert_gmail_account_writes_a_refresh_token_into_its_file() {
3046        let (tmp, path) = temp_settings_path();
3047        let token_file = tmp.path().join("gmail-refresh-token");
3048        fs::create_dir_all(path.parent().unwrap()).unwrap();
3049        fs::write(
3050            &path,
3051            serde_json::json!({ "gmail": { "accounts": { "work": {
3052                "refresh_token_file": token_file.to_str().unwrap(),
3053            }}}})
3054            .to_string(),
3055        )
3056        .unwrap();
3057
3058        Settings::upsert_gmail_account(
3059            &path,
3060            "work",
3061            &[("refresh_token", serde_json::json!("gmail-token"))],
3062        )
3063        .unwrap();
3064
3065        assert!(!fs::read_to_string(&path).unwrap().contains("gmail-token"));
3066        assert_eq!(fs::read_to_string(&token_file).unwrap(), "gmail-token\n");
3067        let settings = Settings::load_from_path(&path).unwrap();
3068        assert!(settings.gmail.accounts["work"].refresh_token.is_none());
3069    }
3070
3071    /// Drive's twin of
3072    /// `upsert_gmail_account_writes_a_bool_value_that_round_trips_through_settings_load`
3073    /// — see that test's doc comment for the PR #1528 review comment this
3074    /// closes.
3075    #[test]
3076    fn upsert_drive_account_writes_a_bool_value_that_round_trips_through_settings_load() {
3077        let (_tmp, path) = temp_settings_path();
3078
3079        Settings::upsert_drive_account(
3080            &path,
3081            "work",
3082            &[("chrome_profile_from_email", serde_json::Value::Bool(true))],
3083        )
3084        .unwrap();
3085
3086        let val = read_json(&path);
3087        assert_eq!(
3088            val["drive"]["accounts"]["work"]["chrome_profile_from_email"],
3089            true
3090        );
3091
3092        let settings = Settings::load_from_path(&path).unwrap();
3093        assert!(
3094            settings.drive.accounts["work"].chrome_profile_from_email,
3095            "the bool field must deserialize back to `true`, not the string \"true\""
3096        );
3097    }
3098
3099    // ── write_permissions (issue #1574) ─────────────────────────────────
3100
3101    #[test]
3102    fn write_permissions_field_round_trips_through_settings_load() {
3103        let (_tmp, path) = temp_settings_path();
3104        fs::create_dir_all(path.parent().unwrap()).unwrap();
3105        fs::write(
3106            &path,
3107            r#"{
3108                "drive": {
3109                    "accounts": {
3110                        "work": {
3111                            "write_permissions": {
3112                                "rules": [
3113                                    {
3114                                        "folder_id": "folder-1",
3115                                        "recursive": true,
3116                                        "allow": ["create", "upload"],
3117                                        "deny": ["edit"]
3118                                    }
3119                                ]
3120                            }
3121                        }
3122                    }
3123                }
3124            }"#,
3125        )
3126        .unwrap();
3127
3128        let settings = Settings::load_from_path(&path).unwrap();
3129        let rules = &settings.drive.accounts["work"].write_permissions.rules;
3130        assert_eq!(rules.len(), 1);
3131        assert_eq!(rules[0].folder_id.as_deref(), Some("folder-1"));
3132        assert!(rules[0].recursive);
3133        assert!(rules[0]
3134            .allow
3135            .contains(&crate::drive::write_gate::DriveOperation::Create));
3136        assert!(rules[0]
3137            .allow
3138            .contains(&crate::drive::write_gate::DriveOperation::Upload));
3139        assert!(rules[0]
3140            .deny
3141            .contains(&crate::drive::write_gate::DriveOperation::Edit));
3142    }
3143
3144    #[test]
3145    fn write_permissions_absent_defaults_to_empty_rules() {
3146        let (_tmp, path) = temp_settings_path();
3147        fs::create_dir_all(path.parent().unwrap()).unwrap();
3148        fs::write(
3149            &path,
3150            r#"{"drive": {"accounts": {"work": {"client_id": "id"}}}}"#,
3151        )
3152        .unwrap();
3153
3154        let settings = Settings::load_from_path(&path).unwrap();
3155        assert!(settings.drive.accounts["work"]
3156            .write_permissions
3157            .rules
3158            .is_empty());
3159    }
3160
3161    /// Mirrors the crate-wide unknown-field-preserving convention every
3162    /// other settings block already guarantees: a future field inside
3163    /// `write_permissions` that this version of `omni-dev` doesn't know
3164    /// about must survive an unrelated read-modify-write cycle untouched,
3165    /// since writes go through the untyped `serde_json::Value` merge path
3166    /// ([`read_or_default_settings`]/[`ensure_object_at`]), never a
3167    /// round-trip through the typed struct.
3168    #[test]
3169    fn write_permissions_unknown_future_field_is_preserved_through_read_modify_write() {
3170        let (_tmp, path) = temp_settings_path();
3171        fs::create_dir_all(path.parent().unwrap()).unwrap();
3172        fs::write(
3173            &path,
3174            r#"{
3175                "drive": {
3176                    "accounts": {
3177                        "work": {
3178                            "write_permissions": {
3179                                "rules": [],
3180                                "future_field": "not yet modeled"
3181                            }
3182                        }
3183                    }
3184                }
3185            }"#,
3186        )
3187        .unwrap();
3188
3189        Settings::upsert_drive_account(
3190            &path,
3191            "work",
3192            &[("client_id", serde_json::Value::String("id".to_string()))],
3193        )
3194        .unwrap();
3195
3196        let val = read_json(&path);
3197        assert_eq!(
3198            val["drive"]["accounts"]["work"]["write_permissions"]["future_field"],
3199            "not yet modeled"
3200        );
3201        assert_eq!(val["drive"]["accounts"]["work"]["client_id"], "id");
3202    }
3203
3204    #[test]
3205    fn remove_drive_account_true_when_present_false_when_absent() {
3206        let (_tmp, path) = temp_settings_path();
3207        fs::create_dir_all(path.parent().unwrap()).unwrap();
3208        fs::write(
3209            &path,
3210            r#"{"drive": {"accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
3211        )
3212        .unwrap();
3213
3214        assert!(Settings::remove_drive_account(&path, "work").unwrap());
3215        let val = read_json(&path);
3216        assert!(val["drive"]["accounts"].get("work").is_none());
3217        assert_eq!(val["drive"]["accounts"]["personal"]["client_id"], "keep");
3218
3219        assert!(!Settings::remove_drive_account(&path, "work").unwrap());
3220    }
3221
3222    #[test]
3223    fn remove_drive_account_clears_default_account_when_it_named_the_removed_account() {
3224        let (_tmp, path) = temp_settings_path();
3225        fs::create_dir_all(path.parent().unwrap()).unwrap();
3226        fs::write(
3227            &path,
3228            r#"{"drive": {"default_account": "work", "accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
3229        )
3230        .unwrap();
3231
3232        assert!(Settings::remove_drive_account(&path, "work").unwrap());
3233        let val = read_json(&path);
3234        assert!(val["drive"].get("default_account").is_none());
3235        assert_eq!(val["drive"]["accounts"]["personal"]["client_id"], "keep");
3236    }
3237
3238    #[test]
3239    fn remove_drive_account_leaves_default_account_untouched_when_it_names_a_different_account() {
3240        let (_tmp, path) = temp_settings_path();
3241        fs::create_dir_all(path.parent().unwrap()).unwrap();
3242        fs::write(
3243            &path,
3244            r#"{"drive": {"default_account": "personal", "accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
3245        )
3246        .unwrap();
3247
3248        assert!(Settings::remove_drive_account(&path, "work").unwrap());
3249        let val = read_json(&path);
3250        assert_eq!(val["drive"]["default_account"], "personal");
3251    }
3252
3253    #[test]
3254    fn remove_drive_account_false_when_file_missing() {
3255        let (_tmp, path) = temp_settings_path();
3256        assert!(!Settings::remove_drive_account(&path, "work").unwrap());
3257        assert!(!path.exists());
3258    }
3259
3260    #[test]
3261    fn set_drive_default_account_sets_and_clears() {
3262        let (_tmp, path) = temp_settings_path();
3263
3264        Settings::set_drive_default_account(&path, Some("work")).unwrap();
3265        assert_eq!(read_json(&path)["drive"]["default_account"], "work");
3266
3267        Settings::set_drive_default_account(&path, None).unwrap();
3268        assert!(read_json(&path)["drive"].get("default_account").is_none());
3269    }
3270
3271    // ── file-id rules (issue #1612) ─────────────────────────────────────
3272
3273    /// Writes `rules` as an account's `write_permissions.rules` and loads it.
3274    fn load_with_rules(rules_json: &str) -> Result<Settings> {
3275        let (_tmp, path) = temp_settings_path();
3276        fs::create_dir_all(path.parent().unwrap()).unwrap();
3277        fs::write(
3278            &path,
3279            format!(
3280                r#"{{"drive":{{"accounts":{{"work":{{"write_permissions":{{"rules":{rules_json}}}}}}}}}}}"#
3281            ),
3282        )
3283        .unwrap();
3284        Settings::load_from_path(&path)
3285    }
3286
3287    #[test]
3288    fn a_file_id_rule_round_trips_through_settings_load() {
3289        let settings =
3290            load_with_rules(r#"[{"file_id":"sheet-1","allow":["sheets-write"]}]"#).unwrap();
3291        let rules = &settings.drive.accounts["work"].write_permissions.rules;
3292        assert_eq!(rules.len(), 1);
3293        assert_eq!(rules[0].file_id.as_deref(), Some("sheet-1"));
3294        assert_eq!(rules[0].folder_id, None);
3295        assert!(!rules[0].recursive);
3296        assert!(rules[0]
3297            .allow
3298            .contains(&crate::drive::write_gate::DriveOperation::SheetsWrite));
3299    }
3300
3301    #[test]
3302    fn folder_and_file_rules_coexist_in_one_list() {
3303        let settings = load_with_rules(
3304            r#"[{"folder_id":"f1","recursive":true,"allow":["create"]},
3305                {"file_id":"x1","deny":["edit"]}]"#,
3306        )
3307        .unwrap();
3308        let rules = &settings.drive.accounts["work"].write_permissions.rules;
3309        assert_eq!(rules.len(), 2);
3310        assert_eq!(rules[0].folder_id.as_deref(), Some("f1"));
3311        assert_eq!(rules[1].file_id.as_deref(), Some("x1"));
3312    }
3313
3314    #[test]
3315    fn a_rule_naming_no_target_fails_the_whole_load() {
3316        // Fail-closed: `active_account_rules` turns a load error into an
3317        // empty rule set, which denies every write.
3318        assert!(load_with_rules(r#"[{"allow":["create"]}]"#).is_err());
3319    }
3320
3321    #[test]
3322    fn a_rule_naming_both_targets_fails_the_whole_load() {
3323        assert!(load_with_rules(r#"[{"folder_id":"f1","file_id":"x1"}]"#).is_err());
3324    }
3325
3326    #[test]
3327    fn recursive_true_on_a_file_rule_fails_the_whole_load() {
3328        assert!(load_with_rules(r#"[{"file_id":"x1","recursive":true}]"#).is_err());
3329    }
3330
3331    #[test]
3332    fn an_older_binary_would_fail_closed_on_a_file_rule() {
3333        // The backward-compatibility claim, pinned. An older binary has
3334        // `folder_id: String` (required), so a file rule is a missing-field
3335        // error there — exactly what this asserts by omitting `folder_id`
3336        // from a shape that requires one. `Settings::load()` then fails and
3337        // `active_account_rules` degrades to zero rules: every write denied,
3338        // never silently widened. Same mechanism ADR-0073 §3 accepted for a
3339        // new `DriveOperation` variant.
3340        #[derive(Debug, serde::Deserialize)]
3341        #[allow(dead_code)]
3342        struct OldFolderPermissionRule {
3343            folder_id: String,
3344        }
3345        let err = serde_json::from_str::<OldFolderPermissionRule>(r#"{"file_id":"x1"}"#)
3346            .unwrap_err()
3347            .to_string();
3348        assert!(err.contains("folder_id"), "{err}");
3349    }
3350
3351    #[test]
3352    fn upsert_drive_account_preserves_file_id_rules_it_cannot_understand() {
3353        // Settings *writes* go through an untyped `serde_json::Value`
3354        // merge, so an older binary running `drive auth login` cannot drop
3355        // the file rules it failed to parse.
3356        let (_tmp, path) = temp_settings_path();
3357        fs::create_dir_all(path.parent().unwrap()).unwrap();
3358        fs::write(
3359            &path,
3360            r#"{"drive":{"accounts":{"work":{"write_permissions":{"rules":[
3361                {"file_id":"sheet-1","allow":["sheets-write"]}]}}}}}"#,
3362        )
3363        .unwrap();
3364
3365        Settings::upsert_drive_account(
3366            &path,
3367            "work",
3368            &[("email", serde_json::Value::String("a@b.c".to_string()))],
3369        )
3370        .unwrap();
3371
3372        let reloaded = Settings::load_from_path(&path).unwrap();
3373        let rules = &reloaded.drive.accounts["work"].write_permissions.rules;
3374        assert_eq!(rules.len(), 1);
3375        assert_eq!(rules[0].file_id.as_deref(), Some("sheet-1"));
3376    }
3377}