Skip to main content

omni_dev/utils/
settings.rs

1//! Settings and configuration utilities.
2//!
3//! This module provides functionality to read settings from $HOME/.omni-dev/settings.json
4//! and use them as a fallback for environment variables.
5//!
6//! It also owns the write side: [`Settings::upsert_env_vars_in`] and
7//! [`Settings::remove_env_vars_in`] (plus their base-`env` shorthands
8//! [`Settings::upsert_env_vars`] / [`Settings::remove_env_vars`]) are the only
9//! production paths that mutate the settings file. Writes target the active
10//! profile's `env` when a profile is given, mirroring the read-side isolation
11//! of [`Settings::resolve_with`] (issue #1116). Because the `env` maps hold
12//! credentials (Atlassian, Datadog), every write is hardened: parent directory
13//! `0700`, file `0600`, re-tightened on each write (issue #1128).
14
15use std::collections::{BTreeSet, HashMap};
16use std::fmt;
17use std::fs;
18use std::path::{Path, PathBuf};
19use std::sync::Mutex;
20
21use anyhow::{Context, Result};
22use serde::Deserialize;
23
24use crate::utils::env::{EnvSource, SystemEnv};
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/// Settings loaded from $HOME/.omni-dev/settings.json.
376#[derive(Debug, Default, Deserialize)]
377pub struct Settings {
378    /// Environment variable overrides — the default bundle, consulted only when
379    /// **no** profile is active.
380    #[serde(default)]
381    pub env: HashMap<String, String>,
382
383    /// Named profiles. Selecting one replaces the base `env` in the fallback
384    /// chain (isolated / AWS-faithful); see [`Settings::resolve_with`].
385    #[serde(default)]
386    pub profiles: HashMap<String, Profile>,
387
388    /// MCP server defaults (issue #620); an absent block yields
389    /// [`McpSettings::default`].
390    #[serde(default)]
391    pub mcp: McpSettings,
392
393    /// Named Gmail accounts (issue #1500); an absent block yields
394    /// [`GmailSettings::default`], which is an empty account map.
395    #[serde(default)]
396    pub gmail: GmailSettings,
397
398    /// Named Google Drive accounts (issue #1520); an absent block yields
399    /// [`DriveSettings::default`], which is an empty account map.
400    #[serde(default)]
401    pub drive: DriveSettings,
402
403    /// Global Drive lease policy (issue #1677); an absent block yields
404    /// [`LeaseSettings::default`].
405    #[serde(default)]
406    pub lease: LeaseSettings,
407}
408
409/// Returns the active profile name from `raw` (the process environment), or
410/// `None` when `OMNI_DEV_PROFILE` is unset or empty.
411///
412/// Reads the **raw** env only, so it is pure over the injected source and never
413/// resolves through the profile fallback.
414pub fn active_profile_from<E: EnvSource>(raw: &E) -> Option<String> {
415    raw.var(PROFILE_ENV_VAR).filter(|s| !s.is_empty())
416}
417
418/// Renders ` (profile '<name>')` for credential-store CLI messages, or the
419/// empty string when no profile is active — so `auth login`/`logout` output
420/// names the env map it actually wrote to (issue #1116).
421#[must_use]
422pub fn profile_suffix(profile: Option<&str>) -> String {
423    profile.map_or_else(String::new, |name| format!(" (profile '{name}')"))
424}
425
426/// An [`EnvSource`](crate::utils::env::EnvSource) with the settings/profile
427/// fallback — the value form of [`get_env_var`].
428///
429/// Reads the real process environment first, then the active profile's `env`
430/// (or the base `env` when no profile is active) in
431/// `$HOME/.omni-dev/settings.json`.
432///
433/// Pass `&SettingsEnv::load()` from a thin production wrapper; tests inject a
434/// pure `MapEnv` into the same `*_with(&impl EnvSource, …)` seam instead of
435/// mutating the process environment.
436#[derive(Debug, Default)]
437pub struct SettingsEnv {
438    settings: Settings,
439    active_profile: Option<String>,
440}
441
442impl SettingsEnv {
443    /// Loads settings from the default location, falling back to an empty
444    /// settings map if they are absent or unreadable (env-only behaviour),
445    /// warning first if the file exists but fails to parse (issue #1744).
446    /// The active profile is read from `OMNI_DEV_PROFILE`.
447    pub fn load() -> Self {
448        Self::load_with_profile(active_profile_from(&SystemEnv).as_deref())
449    }
450
451    /// Like [`load`](Self::load) but with the active profile supplied
452    /// explicitly — for tests and embedders that select a profile without
453    /// setting `OMNI_DEV_PROFILE` in the process environment.
454    pub fn load_with_profile(profile: Option<&str>) -> Self {
455        Self {
456            settings: Settings::load_or_warn_default(),
457            active_profile: profile.map(str::to_string),
458        }
459    }
460
461    /// Wraps an already-loaded [`Settings`], skipping the disk read/parse
462    /// [`load`](Self::load)/[`load_with_profile`](Self::load_with_profile)
463    /// perform — for callers (e.g. `load_credentials_for`/`status_for` in
464    /// `crate::gmail::auth`/`crate::drive::auth`) that already loaded
465    /// `Settings` to resolve an account and would otherwise discard it just
466    /// to re-read the same file a second time (issue #1533).
467    pub fn from_settings(settings: Settings, profile: Option<&str>) -> Self {
468        Self {
469            settings,
470            active_profile: profile.map(str::to_string),
471        }
472    }
473}
474
475impl EnvSource for SettingsEnv {
476    fn var(&self, key: &str) -> Option<String> {
477        self.settings
478            .resolve_with(&SystemEnv, self.active_profile.as_deref(), key)
479    }
480
481    fn var_pair(&self, key: &str, file_key: &str) -> (Option<String>, Option<String>) {
482        self.settings
483            .resolve_pair_with(&SystemEnv, self.active_profile.as_deref(), key, file_key)
484    }
485}
486
487/// A borrowed [`SettingsEnv`]: the settings/profile fallback over an
488/// already-loaded [`Settings`], for callers that keep using the `Settings`
489/// afterwards. Built by [`Settings::env_source`].
490#[derive(Debug)]
491pub struct SettingsEnvRef<'a> {
492    settings: &'a Settings,
493    active_profile: Option<String>,
494}
495
496impl EnvSource for SettingsEnvRef<'_> {
497    fn var(&self, key: &str) -> Option<String> {
498        self.settings
499            .resolve_with(&SystemEnv, self.active_profile.as_deref(), key)
500    }
501
502    fn var_pair(&self, key: &str, file_key: &str) -> (Option<String>, Option<String>) {
503        self.settings
504            .resolve_pair_with(&SystemEnv, self.active_profile.as_deref(), key, file_key)
505    }
506}
507
508/// Process-wide de-duplication for [`Settings::load_or_warn_default`]'s
509/// warning (issue #1744). Remembers the last failure warned about: a repeat
510/// of the same failure is silent, while a successful load forgets it — so a
511/// long-lived process (the daemon, the MCP server) warns afresh if the file is
512/// fixed and later broken again, even with an identical error.
513struct LoadWarnDedup(Mutex<Option<String>>);
514
515impl LoadWarnDedup {
516    const fn new() -> Self {
517        Self(Mutex::new(None))
518    }
519
520    /// Records one load's outcome (`None` = success, `Some` = the failure's
521    /// message) and returns whether that failure should be warned about.
522    fn observe(&self, failure: Option<&str>) -> bool {
523        let mut last = self
524            .0
525            .lock()
526            .unwrap_or_else(std::sync::PoisonError::into_inner);
527        match failure {
528            None => {
529                *last = None;
530                false
531            }
532            Some(message) if last.as_deref() == Some(message) => false,
533            Some(message) => {
534                *last = Some(message.to_string());
535                true
536            }
537        }
538    }
539}
540
541static LOAD_WARN_DEDUP: LoadWarnDedup = LoadWarnDedup::new();
542
543/// Whether `key` is a registered secret, so it has a `_FILE` companion.
544fn is_secret_env_var(key: &str) -> bool {
545    secret_env::SECRET_ENV_VARS.contains(&key)
546}
547
548impl Settings {
549    /// Loads settings from the default location.
550    pub fn load() -> Result<Self> {
551        let settings_path = Self::get_settings_path()?;
552        Self::load_from_path(&settings_path)
553    }
554
555    /// Loads settings from the default location, warning and falling back to
556    /// [`Settings::default`] if the file exists but fails to read or parse —
557    /// the disk boundary every non-`Result` call site shares (issue #1744).
558    /// A *missing* file is not an error and never warns (see
559    /// [`Self::load_from_path`]).
560    ///
561    /// The warning fires once per distinct failure per process, not once per
562    /// call: a single command loads settings several times (client creation,
563    /// then each account/lease helper), and repeating an identical warning
564    /// for every read buried the refusal it explains. A successful load resets
565    /// this, so a long-lived process warns again if the file breaks again.
566    pub fn load_or_warn_default() -> Self {
567        match Self::load() {
568            Ok(settings) => {
569                LOAD_WARN_DEDUP.observe(None);
570                settings
571            }
572            Err(e) => {
573                let message = format!("{e:#}");
574                if LOAD_WARN_DEDUP.observe(Some(&message)) {
575                    tracing::warn!(
576                        "{message}; falling back to default settings for this invocation — \
577                         any settings.json configuration is being ignored"
578                    );
579                }
580                Self::default()
581            }
582        }
583    }
584
585    /// Loads just the [`mcp`](McpSettings) section, warning and falling back
586    /// to its defaults when the settings file is absent or unreadable — so
587    /// the MCP server always boots even with a malformed `settings.json`
588    /// (issue #620), same warn-then-default contract as
589    /// [`Self::load_or_warn_default`] (issue #1744).
590    pub fn load_mcp() -> McpSettings {
591        Self::load_or_warn_default().mcp
592    }
593
594    /// Loads settings from a specific path.
595    pub fn load_from_path<P: AsRef<Path>>(path: P) -> Result<Self> {
596        let path = path.as_ref();
597
598        // If file doesn't exist, return default settings
599        if !path.exists() {
600            return Ok(Self::default());
601        }
602
603        // Read and parse the settings file
604        let content = fs::read_to_string(path)
605            .with_context(|| format!("Failed to read settings file: {}", path.display()))?;
606
607        serde_json::from_str::<Self>(&content)
608            .with_context(|| format!("Failed to parse settings file: {}", path.display()))
609    }
610
611    /// Returns the default settings path.
612    pub fn get_settings_path() -> Result<PathBuf> {
613        let home_dir = dirs::home_dir().context("Failed to determine home directory")?;
614
615        Ok(home_dir.join(".omni-dev").join("settings.json"))
616    }
617
618    /// Returns an environment variable with fallback to settings, honouring the
619    /// active profile from `OMNI_DEV_PROFILE`.
620    pub fn get_env_var(&self, key: &str) -> Option<String> {
621        self.resolve_with(&SystemEnv, active_profile_from(&SystemEnv).as_deref(), key)
622    }
623
624    /// Isolated / AWS-faithful resolution: `raw` (the process environment) wins;
625    /// then the active profile's `env` if `active` is set, else the base `env`.
626    /// The base map is **not** consulted when a profile is active, so a missing
627    /// key fails loud rather than silently reusing a default credential against
628    /// the wrong tenant.
629    ///
630    /// This is the pure seam: production wrappers pass `&SystemEnv`; tests pass
631    /// a `MapEnv` and an explicit `active`, mutating no process-global state.
632    pub fn resolve_with<E: EnvSource>(
633        &self,
634        raw: &E,
635        active: Option<&str>,
636        key: &str,
637    ) -> Option<String> {
638        self.resolve_with_source(raw, active, key)
639            .map(|(value, _)| value)
640    }
641
642    /// Resolves `key` and `file_key` **as a pair from one layer**, for the
643    /// secret resolver ([`crate::utils::secret_env`], ADR-0089): the raw
644    /// environment's pair if it sets either (even to an empty value, which
645    /// neutralises the lower layers exactly as [`Settings::resolve_with`]
646    /// does); otherwise the active profile's `env` pair, or the base `env`
647    /// pair when no profile is active. So `NAME_FILE` in the process env
648    /// overrides `NAME` in settings.json, while both in one layer is left for
649    /// the resolver to reject.
650    pub fn resolve_pair_with<E: EnvSource>(
651        &self,
652        raw: &E,
653        active: Option<&str>,
654        key: &str,
655        file_key: &str,
656    ) -> (Option<String>, Option<String>) {
657        let raw_pair = (raw.var(key), raw.var(file_key));
658        if raw_pair.0.is_some() || raw_pair.1.is_some() {
659            return raw_pair;
660        }
661        let layer = match active {
662            Some(name) => self.profiles.get(name).map(|p| &p.env),
663            None => Some(&self.env),
664        };
665        layer.map_or((None, None), |env| {
666            (env.get(key).cloned(), env.get(file_key).cloned())
667        })
668    }
669
670    /// An [`EnvSource`] over these settings with the process environment in
671    /// front and the active profile read from `OMNI_DEV_PROFILE` — the
672    /// borrowed form of [`SettingsEnv`], for the secret resolver.
673    #[must_use]
674    pub fn env_source(&self) -> SettingsEnvRef<'_> {
675        SettingsEnvRef {
676            settings: self,
677            active_profile: active_profile_from(&SystemEnv),
678        }
679    }
680
681    /// Like [`Settings::resolve_with`], but also reports which layer supplied
682    /// the value: the raw process environment, the active profile's `env`, or
683    /// the base `env` (issue #1143). Same precedence, same profile isolation.
684    ///
685    /// A [`EnvValueSource::CliFlag`] attribution is layered on top by
686    /// [`get_env_var_sourced`], which knows about flag exports; this resolver
687    /// only distinguishes what it can see.
688    pub fn resolve_with_source<E: EnvSource>(
689        &self,
690        raw: &E,
691        active: Option<&str>,
692        key: &str,
693    ) -> Option<(String, EnvValueSource)> {
694        if let Some(value) = raw.var(key) {
695            return Some((value, EnvValueSource::ProcessEnv));
696        }
697        match active {
698            Some(name) => self
699                .profiles
700                .get(name)
701                .and_then(|p| p.env.get(key).cloned())
702                .map(|value| (value, EnvValueSource::SettingsProfile(name.to_string()))),
703            None => self
704                .env
705                .get(key)
706                .cloned()
707                .map(|value| (value, EnvValueSource::SettingsEnv)),
708        }
709    }
710
711    /// Merges the given key/value pairs into the base `env` object of the
712    /// settings file at `path` — [`Settings::upsert_env_vars_in`] with no
713    /// profile.
714    pub fn upsert_env_vars(path: &Path, vars: &[(&str, &str)]) -> Result<()> {
715        Self::upsert_env_vars_in(path, None, vars)
716    }
717
718    /// Merges the given key/value pairs into the `env` object targeted by
719    /// `profile` — `profiles.<name>.env` when `Some`, the base `env` when
720    /// `None` — creating the file, its parent directory, and any missing
721    /// intermediate objects as needed. Writes therefore land where
722    /// [`Settings::resolve_with`] will look for them (issue #1116).
723    ///
724    /// A `profile` absent from the file is created rather than rejected; the
725    /// CLI validates the active profile before dispatch, so this only affects
726    /// library callers.
727    ///
728    /// The file is read and written as a generic JSON value, so every other
729    /// field (other profiles, unknown keys) is preserved verbatim. Because the
730    /// `env` maps hold credentials, the write is hardened: parent directory
731    /// `0700`, file `0600` (see [`write_settings`]).
732    pub fn upsert_env_vars_in(
733        path: &Path,
734        profile: Option<&str>,
735        vars: &[(&str, &str)],
736    ) -> Result<()> {
737        let mut settings_value = read_or_default_settings(path)?;
738
739        let env = ensure_env_object(&mut settings_value, profile)?;
740        for (key, value) in vars {
741            env.insert(
742                (*key).to_string(),
743                serde_json::Value::String((*value).to_string()),
744            );
745            // A registered secret's `_FILE` companion in the same map would
746            // make every later read a same-layer conflict (ADR-0089), so the
747            // value just written replaces it.
748            if is_secret_env_var(key) {
749                env.remove(&secret_env::file_var_name(key));
750            }
751        }
752
753        write_settings(path, &settings_value)
754    }
755
756    /// Removes the given keys from the base `env` object of the settings file
757    /// at `path` — [`Settings::remove_env_vars_in`] with no profile.
758    pub fn remove_env_vars(path: &Path, keys: &[&str]) -> Result<bool> {
759        Self::remove_env_vars_in(path, None, keys)
760    }
761
762    /// Removes the given keys from the `env` object targeted by `profile`
763    /// (`profiles.<name>.env` when `Some`, the base `env` when `None`),
764    /// leaving all other settings — including the same keys in other env
765    /// maps — intact.
766    ///
767    /// Returns `true` if any key was present in the targeted map and removed
768    /// (the file is rewritten, hardened as in
769    /// [`Settings::upsert_env_vars_in`]), `false` when the file did not
770    /// exist, the targeted map was absent, or it contained none of the keys
771    /// (the file is left untouched).
772    pub fn remove_env_vars_in(path: &Path, profile: Option<&str>, keys: &[&str]) -> Result<bool> {
773        if !path.exists() {
774            return Ok(false);
775        }
776        let mut settings_value = read_or_default_settings(path)?;
777
778        let mut removed = false;
779        if let Some(env) = env_object_mut(&mut settings_value, profile) {
780            for key in keys {
781                if env.remove(*key).is_some() {
782                    removed = true;
783                }
784                // Logging out must not leave a registered secret still
785                // resolvable through its `_FILE` companion (ADR-0089).
786                if is_secret_env_var(key) && env.remove(&secret_env::file_var_name(key)).is_some() {
787                    removed = true;
788                }
789            }
790        }
791
792        if removed {
793            write_settings(path, &settings_value)?;
794        }
795        Ok(removed)
796    }
797
798    /// Validates that `name` is a known profile, returning a hard error that
799    /// lists the known profiles (sorted) otherwise. Called once at the CLI
800    /// boundary so a typo never silently falls back to base credentials.
801    pub fn validate_profile(&self, name: &str) -> Result<()> {
802        if self.profiles.contains_key(name) {
803            return Ok(());
804        }
805        let known = if self.profiles.is_empty() {
806            "(none)".to_string()
807        } else {
808            let mut names: Vec<&str> = self.profiles.keys().map(String::as_str).collect();
809            names.sort_unstable();
810            names.join(", ")
811        };
812        Err(anyhow::anyhow!(
813            "unknown profile '{name}'; known profiles: {known}"
814        ))
815    }
816
817    /// Merges the given key/value pairs into `gmail.accounts.<account>`,
818    /// creating the file, its parent directory, and any missing intermediate
819    /// objects as needed (issue #1500,
820    /// [ADR-0066](../../docs/adrs/adr-0066.md)). Same hardening and
821    /// unknown-field preservation as [`Settings::upsert_env_vars_in`] — no
822    /// new file-handling code.
823    ///
824    /// `vars` takes [`serde_json::Value`] rather than `&str` (widened in
825    /// #1523, PR #1528 review) because `GmailAccountSettings` has non-string
826    /// fields (`chrome_profile_from_email: bool`) — a hard-coded
827    /// `Value::String` wrap would write the JSON string `"true"` into a
828    /// `bool` field, and since `Settings` deserializes as one unit, the next
829    /// `Settings::load()` would hard-fail parsing the *entire* file. Callers
830    /// writing a string field pass `serde_json::Value::String(...)`
831    /// explicitly.
832    pub fn upsert_gmail_account(
833        path: &Path,
834        account: &str,
835        vars: &[(&str, serde_json::Value)],
836    ) -> Result<()> {
837        upsert_account(path, "gmail", account, vars)
838    }
839
840    /// Removes `gmail.accounts.<account>` entirely — an account is coherent
841    /// as a unit, unlike the key-by-key removal
842    /// [`Settings::remove_env_vars_in`] does. Also clears
843    /// `gmail.default_account` if it named the removed account, so
844    /// resolution (`src/gmail/account.rs::resolve_default_account`) falls
845    /// back to the sole-remaining-account rule instead of hard-erroring on
846    /// a dangling pointer (issue #1529). Returns `true` if the account was
847    /// present and removed, `false` when the file, `gmail`,
848    /// `gmail.accounts`, or the named account did not exist (the file is
849    /// left untouched in that case).
850    pub fn remove_gmail_account(path: &Path, account: &str) -> Result<bool> {
851        if !path.exists() {
852            return Ok(false);
853        }
854        let mut settings_value = read_or_default_settings(path)?;
855
856        let removed = object_at_mut(&mut settings_value, &["gmail", "accounts"])
857            .is_some_and(|accounts| accounts.remove(account).is_some());
858
859        if removed {
860            if let Some(gmail) = object_at_mut(&mut settings_value, &["gmail"]) {
861                if gmail.get("default_account").and_then(|v| v.as_str()) == Some(account) {
862                    gmail.remove("default_account");
863                }
864            }
865            write_settings(path, &settings_value)?;
866        }
867        Ok(removed)
868    }
869
870    /// Sets (`Some`) or clears (`None`) `gmail.default_account`. Always
871    /// writes, mirroring [`Settings::upsert_env_vars_in`]'s unconditional-write
872    /// semantics rather than [`Settings::remove_env_vars_in`]'s
873    /// changed-only one, since this is fundamentally an upsert of a single
874    /// scalar rather than a set of keys.
875    pub fn set_gmail_default_account(path: &Path, account: Option<&str>) -> Result<()> {
876        let mut settings_value = read_or_default_settings(path)?;
877
878        match account {
879            Some(name) => {
880                let gmail = ensure_object_at(&mut settings_value, &["gmail"])?;
881                gmail.insert(
882                    "default_account".to_string(),
883                    serde_json::Value::String(name.to_string()),
884                );
885            }
886            None => {
887                if let Some(gmail) = object_at_mut(&mut settings_value, &["gmail"]) {
888                    gmail.remove("default_account");
889                }
890            }
891        }
892
893        write_settings(path, &settings_value)
894    }
895
896    /// Merges the given key/value pairs into `drive.accounts.<account>`,
897    /// creating the file, its parent directory, and any missing intermediate
898    /// objects as needed (issue #1522,
899    /// [ADR-0069](../../docs/adrs/adr-0069.md)). Same hardening and
900    /// unknown-field preservation as [`Settings::upsert_env_vars_in`] — no
901    /// new file-handling code.
902    ///
903    /// `vars` takes [`serde_json::Value`], not `&str` — see
904    /// [`Settings::upsert_gmail_account`]'s doc comment for why (this
905    /// helper's twin, and the write path the PR #1528 review comment
906    /// flagged the bug against).
907    pub fn upsert_drive_account(
908        path: &Path,
909        account: &str,
910        vars: &[(&str, serde_json::Value)],
911    ) -> Result<()> {
912        upsert_account(path, "drive", account, vars)
913    }
914
915    /// Removes `drive.accounts.<account>` entirely — an account is coherent
916    /// as a unit, exactly like [`Settings::remove_gmail_account`]. Also
917    /// clears `drive.default_account` if it named the removed account, for
918    /// the same dangling-pointer reason (issue #1529) — Drive has no
919    /// legacy-credential fallback to soften a stale default the way
920    /// Gmail's resolution table can (ADR-0069 §3), so the failure would be
921    /// total. Returns `true` if the account was present and removed,
922    /// `false` when the file, `drive`, `drive.accounts`, or the named
923    /// account did not exist (the file is left untouched in that case).
924    pub fn remove_drive_account(path: &Path, account: &str) -> Result<bool> {
925        if !path.exists() {
926            return Ok(false);
927        }
928        let mut settings_value = read_or_default_settings(path)?;
929
930        let removed = object_at_mut(&mut settings_value, &["drive", "accounts"])
931            .is_some_and(|accounts| accounts.remove(account).is_some());
932
933        if removed {
934            if let Some(drive) = object_at_mut(&mut settings_value, &["drive"]) {
935                if drive.get("default_account").and_then(|v| v.as_str()) == Some(account) {
936                    drive.remove("default_account");
937                }
938            }
939            write_settings(path, &settings_value)?;
940        }
941        Ok(removed)
942    }
943
944    /// Sets (`Some`) or clears (`None`) `drive.default_account`. Always
945    /// writes, mirroring [`Settings::set_gmail_default_account`]'s
946    /// unconditional-write semantics — fundamentally an upsert of a single
947    /// scalar rather than a set of keys.
948    pub fn set_drive_default_account(path: &Path, account: Option<&str>) -> Result<()> {
949        let mut settings_value = read_or_default_settings(path)?;
950
951        match account {
952            Some(name) => {
953                let drive = ensure_object_at(&mut settings_value, &["drive"])?;
954                drive.insert(
955                    "default_account".to_string(),
956                    serde_json::Value::String(name.to_string()),
957                );
958            }
959            None => {
960                if let Some(drive) = object_at_mut(&mut settings_value, &["drive"]) {
961                    drive.remove("default_account");
962                }
963            }
964        }
965
966        write_settings(path, &settings_value)
967    }
968}
969
970/// Navigates `root` to the env object targeted by `profile` — the base `env`
971/// when `None`, `profiles.<name>.env` when `Some`. Thin specialization of
972/// [`ensure_object_at`] for the two-level `env`/`profiles.<name>.env` shape.
973fn ensure_env_object<'a>(
974    root: &'a mut serde_json::Value,
975    profile: Option<&str>,
976) -> Result<&'a mut serde_json::Map<String, serde_json::Value>> {
977    match profile {
978        Some(name) => ensure_object_at(root, &["profiles", name, "env"]),
979        None => ensure_object_at(root, &["env"]),
980    }
981}
982
983/// Navigates `root` to the env object targeted by `profile`, or `None` when
984/// any node on the way is absent or not an object. Thin specialization of
985/// [`object_at_mut`] for the two-level `env`/`profiles.<name>.env` shape.
986fn env_object_mut<'a>(
987    root: &'a mut serde_json::Value,
988    profile: Option<&str>,
989) -> Option<&'a mut serde_json::Map<String, serde_json::Value>> {
990    match profile {
991        Some(name) => object_at_mut(root, &["profiles", name, "env"]),
992        None => object_at_mut(root, &["env"]),
993    }
994}
995
996/// Navigates `root` through each of `segments` in turn, creating missing
997/// intermediate objects and replacing non-object nodes along the way, and
998/// returns the object at the end of the path. The creating counterpart of
999/// [`object_at_mut`], for upserts. Generalizes what were previously two
1000/// hardcoded two-level walks (`env`, `profiles.<name>.env`) to an arbitrary
1001/// depth, so a third settings substructure (`gmail.accounts.<name>`, issue
1002/// #1500) reuses the same file-handling code rather than duplicating it.
1003fn ensure_object_at<'a>(
1004    root: &'a mut serde_json::Value,
1005    segments: &[&str],
1006) -> Result<&'a mut serde_json::Map<String, serde_json::Value>> {
1007    let mut current = root;
1008    for segment in segments {
1009        if !current
1010            .get(*segment)
1011            .is_some_and(serde_json::Value::is_object)
1012        {
1013            current[*segment] = serde_json::json!({});
1014        }
1015        current = current
1016            .get_mut(*segment)
1017            .context("Internal error: target key missing immediately after being created")?;
1018    }
1019    current
1020        .as_object_mut()
1021        .context("Internal error: target key is not an object after initialization")
1022}
1023
1024/// Navigates `root` through each of `segments` in turn, or returns `None`
1025/// when any node on the way is absent or not an object. The non-creating
1026/// counterpart of [`ensure_object_at`], for removals.
1027fn object_at_mut<'a>(
1028    root: &'a mut serde_json::Value,
1029    segments: &[&str],
1030) -> Option<&'a mut serde_json::Map<String, serde_json::Value>> {
1031    let mut current = root;
1032    for segment in segments {
1033        current = current.get_mut(*segment)?;
1034    }
1035    current.as_object_mut()
1036}
1037
1038/// Reads and parses the settings file at `path` as a generic JSON value
1039/// (preserving unknown fields), or returns `{}` when the file does not exist.
1040fn read_or_default_settings(path: &Path) -> Result<serde_json::Value> {
1041    if path.exists() {
1042        let content = fs::read_to_string(path)
1043            .with_context(|| format!("Failed to read {}", path.display()))?;
1044        serde_json::from_str(&content)
1045            .with_context(|| format!("Failed to parse {}", path.display()))
1046    } else {
1047        Ok(serde_json::json!({}))
1048    }
1049}
1050
1051/// The named-account secret fields that accept a `<field>_file` companion
1052/// (#2008).
1053const ACCOUNT_SECRET_FIELDS: &[&str] = &["client_secret", "refresh_token"];
1054
1055/// The shared body of [`Settings::upsert_gmail_account`] and
1056/// [`Settings::upsert_drive_account`]: merges `vars` into
1057/// `<section>.accounts.<account>`.
1058///
1059/// A secret field ([`ACCOUNT_SECRET_FIELDS`]) whose entry already names a
1060/// `<field>_file` is written into that file instead, and the plain field is
1061/// removed, so the secret never lands in settings.json and the entry can't
1062/// hold both (#2008). Every file is checked
1063/// ([`secret_env::plan_secret_file_write`]) before any is written, and all
1064/// are written before settings.json, so a refused or failed secret write
1065/// leaves settings.json untouched.
1066///
1067/// A `client_secret_file` that holds another value is not replaced when the
1068/// incoming `client_id` differs from the entry's: that file is most likely
1069/// shared with accounts of the other OAuth client, and replacing it would
1070/// break them.
1071fn upsert_account(
1072    path: &Path,
1073    section: &str,
1074    account: &str,
1075    vars: &[(&str, serde_json::Value)],
1076) -> Result<()> {
1077    let mut settings_value = read_or_default_settings(path)?;
1078
1079    let entry = ensure_object_at(&mut settings_value, &[section, "accounts", account])?;
1080    let stored_client_id = entry
1081        .get("client_id")
1082        .and_then(serde_json::Value::as_str)
1083        .map(str::to_string);
1084    let mut secret_writes = Vec::new();
1085    for (key, value) in vars {
1086        if let Some(file) = account_secret_file(entry, key, value) {
1087            let label = format!("{section}.accounts.{account}.{key}_file");
1088            let secret = crate::utils::secret::Secret::new(value.as_str().unwrap_or_default());
1089            if let Some(write) =
1090                secret_env::plan_secret_file_write(&label, Path::new(&file), &secret)?
1091            {
1092                if *key == "client_secret" && write.replaces_existing() {
1093                    refuse_client_change(&label, stored_client_id.as_deref(), vars)?;
1094                }
1095                secret_writes.push((write, secret));
1096            } // 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"
1097            entry.remove(*key);
1098        } else {
1099            entry.insert((*key).to_string(), value.clone());
1100        }
1101    }
1102
1103    for (write, secret) in secret_writes {
1104        write.write(&secret)?;
1105    }
1106    write_settings(path, &settings_value)
1107}
1108
1109/// Refuses to replace the `client_secret_file` named by `label` when `vars`
1110/// carries a `client_id` other than the entry's `stored` one — see
1111/// [`upsert_account`].
1112fn refuse_client_change(
1113    label: &str,
1114    stored: Option<&str>,
1115    vars: &[(&str, serde_json::Value)],
1116) -> Result<()> {
1117    let incoming = vars
1118        .iter()
1119        .find(|(key, _)| *key == "client_id")
1120        .and_then(|(_, value)| value.as_str());
1121    if let (Some(stored), Some(incoming)) = (stored, incoming) {
1122        if stored != incoming {
1123            anyhow::bail!(
1124                "{label} holds the secret of OAuth client {stored}, not {incoming}; refusing to \
1125                 replace it, since other accounts may share it. Point {label} at a file of its \
1126                 own, or remove it to store the secret in settings.json"
1127            );
1128        }
1129    }
1130    Ok(())
1131}
1132
1133/// Resolves the secret field `<section>.accounts.<account>.<field>` from its
1134/// plain `value` or its `<field>_file` companion, with the `<NAME>_FILE`
1135/// rules (#2008); errors name the full settings key. `Ok(None)` means
1136/// neither is set.
1137///
1138/// # Errors
1139///
1140/// See [`secret_env::SecretEnvError`].
1141pub(crate) fn account_secret(
1142    section: &str,
1143    account: &str,
1144    field: &str,
1145    value: Option<&str>,
1146    file: Option<&str>,
1147) -> std::result::Result<Option<crate::utils::secret::Secret>, secret_env::SecretEnvError> {
1148    let name = format!("{section}.accounts.{account}.{field}");
1149    secret_env::resolve_secret_pair(&name, value, &format!("{name}_file"), file)
1150}
1151
1152/// Whether a named-account secret field or its `_file` companion is set
1153/// (non-empty), without reading the file — for presence-only reports. Only
1154/// the MCP `*_auth_status` tools report a named account's presence flags.
1155#[cfg(feature = "mcp")]
1156pub(crate) fn account_secret_is_set(value: Option<&str>, file: Option<&str>) -> bool {
1157    value.is_some_and(|v| !v.is_empty()) || file.is_some_and(|f| !f.is_empty())
1158}
1159
1160/// The `<key>_file` path to write `value` into instead of `entry.<key>`, when
1161/// `key` is a secret field, `value` a string, and the companion set.
1162fn account_secret_file(
1163    entry: &serde_json::Map<String, serde_json::Value>,
1164    key: &str,
1165    value: &serde_json::Value,
1166) -> Option<String> {
1167    if !ACCOUNT_SECRET_FIELDS.contains(&key) || !value.is_string() {
1168        return None;
1169    }
1170    entry
1171        .get(&format!("{key}_file"))
1172        .and_then(serde_json::Value::as_str)
1173        .filter(|file| !file.is_empty())
1174        .map(str::to_string)
1175}
1176
1177/// The single hardened write site for the settings file: creates the parent
1178/// directory `0700`, writes the pretty-printed JSON through a `0600` handle
1179/// (no window where a fresh file is world-readable), and re-tightens a
1180/// pre-existing looser-permission file on every write (issue #1128).
1181fn write_settings(path: &Path, value: &serde_json::Value) -> Result<()> {
1182    if let Some(parent) = path.parent() {
1183        if !parent.as_os_str().is_empty() {
1184            crate::daemon::paths::ensure_dir_0700(parent)?;
1185        }
1186    }
1187    let formatted =
1188        serde_json::to_string_pretty(value).context("Failed to serialize settings JSON")?;
1189    write_file_0600(path, &formatted)
1190        .with_context(|| format!("Failed to write {}", path.display()))?;
1191    crate::daemon::paths::set_file_0600(path)?;
1192    Ok(())
1193}
1194
1195/// Creates/truncates `path` with owner-only (`0600`) permissions on Unix.
1196#[cfg(unix)]
1197fn write_file_0600(path: &Path, contents: &str) -> std::io::Result<()> {
1198    use std::io::Write;
1199    use std::os::unix::fs::OpenOptionsExt;
1200
1201    let mut file = fs::OpenOptions::new()
1202        .write(true)
1203        .create(true)
1204        .truncate(true)
1205        .mode(0o600)
1206        .open(path)?;
1207    file.write_all(contents.as_bytes())
1208}
1209
1210/// Non-Unix fallback: a plain write ([`set_file_0600`](crate::daemon::paths::set_file_0600)
1211/// is a no-op there too).
1212#[cfg(not(unix))]
1213fn write_file_0600(path: &Path, contents: &str) -> std::io::Result<()> {
1214    fs::write(path, contents)
1215}
1216
1217/// Returns an environment variable with fallback to settings, honouring the
1218/// active profile from `OMNI_DEV_PROFILE`.
1219pub fn get_env_var(key: &str) -> Result<String> {
1220    get_env_var_with(&SystemEnv, Settings::load, key)
1221}
1222
1223/// Like [`get_env_var`], but also reports where the value came from.
1224///
1225/// The source is a command-line flag export, the process environment, or a
1226/// settings.json `env` map (issue #1143) — for warnings about
1227/// security-sensitive values (e.g. the claude-cli escape hatches) that
1228/// should name their source.
1229pub fn get_env_var_sourced(key: &str) -> Result<(String, EnvValueSource)> {
1230    get_env_var_sourced_with(&SystemEnv, Settings::load, exported_by_cli_flag(key), key)
1231}
1232
1233/// Pure core of [`get_env_var`]: [`get_env_var_sourced_with`] with the source
1234/// dropped.
1235fn get_env_var_with<E, F>(env: &E, load: F, key: &str) -> Result<String>
1236where
1237    E: EnvSource,
1238    F: FnOnce() -> Result<Settings>,
1239{
1240    get_env_var_sourced_with(env, load, false, key).map(|(value, _)| value)
1241}
1242
1243/// Pure core of [`get_env_var_sourced`]: `env` is the raw source, `load`
1244/// produces the settings lazily — it is invoked only on a raw-env miss,
1245/// preserving the no-disk fast path — and `from_cli_flag` says whether a flag
1246/// exported `key` this invocation (injected so tests never touch the
1247/// process-global flag registry). Tests inject a `MapEnv` and a closure
1248/// returning `Ok`/`Err` to cover both the resolved and load-failure branches
1249/// without touching disk.
1250fn get_env_var_sourced_with<E, F>(
1251    env: &E,
1252    load: F,
1253    from_cli_flag: bool,
1254    key: &str,
1255) -> Result<(String, EnvValueSource)>
1256where
1257    E: EnvSource,
1258    F: FnOnce() -> Result<Settings>,
1259{
1260    // A raw process-env hit short-circuits without loading settings from disk.
1261    // A flag export always lands in the process env, so the flag attribution
1262    // only ever applies on this branch.
1263    if let Some(value) = env.var(key) {
1264        let source = if from_cli_flag {
1265            EnvValueSource::CliFlag
1266        } else {
1267            EnvValueSource::ProcessEnv
1268        };
1269        return Ok((value, source));
1270    }
1271    match load() {
1272        Ok(settings) => settings
1273            .resolve_with_source(env, active_profile_from(env).as_deref(), key)
1274            .ok_or_else(|| anyhow::anyhow!("Environment variable not found: {key}")),
1275        Err(err) => {
1276            // If we couldn't load settings, just return the original env var error
1277            Err(anyhow::anyhow!("Environment variable not found: {key}").context(err))
1278        }
1279    }
1280}
1281
1282/// Tries multiple environment variables with fallback to settings.
1283pub fn get_env_vars(keys: &[&str]) -> Result<String> {
1284    for key in keys {
1285        if let Ok(value) = get_env_var(key) {
1286            return Ok(value);
1287        }
1288    }
1289
1290    Err(anyhow::anyhow!(
1291        "None of the environment variables found: {keys:?}"
1292    ))
1293}
1294
1295#[cfg(test)]
1296#[allow(clippy::unwrap_used, clippy::expect_used)]
1297mod tests {
1298    use super::*;
1299    use crate::test_support::env::MapEnv;
1300    use std::env;
1301    use std::fs;
1302    use tempfile::TempDir;
1303
1304    /// Builds a `Settings` with a base `env` and one profile, for the pure
1305    /// resolver tests (no disk, no process env).
1306    fn settings_with_profile() -> Settings {
1307        let mut base = HashMap::new();
1308        base.insert("ATLASSIAN_EMAIL".to_string(), "base@x.com".to_string());
1309        base.insert("SHARED".to_string(), "base-shared".to_string());
1310
1311        let mut work_env = HashMap::new();
1312        work_env.insert("ATLASSIAN_EMAIL".to_string(), "me@work.com".to_string());
1313
1314        let mut profiles = HashMap::new();
1315        profiles.insert("work".to_string(), Profile { env: work_env });
1316
1317        Settings {
1318            env: base,
1319            profiles,
1320            ..Settings::default()
1321        }
1322    }
1323
1324    #[test]
1325    fn settings_load_from_path() {
1326        // Create a temporary directory (use current dir to avoid TMPDIR issues in tarpaulin)
1327        let temp_dir = {
1328            std::fs::create_dir_all("tmp").ok();
1329            TempDir::new_in("tmp").unwrap()
1330        };
1331        let settings_path = temp_dir.path().join("settings.json");
1332
1333        // Create a test settings file
1334        let settings_json = r#"{
1335            "env": {
1336                "TEST_VAR": "test_value",
1337                "CLAUDE_API_KEY": "test_api_key"
1338            }
1339        }"#;
1340        fs::write(&settings_path, settings_json).unwrap();
1341
1342        // Load settings
1343        let settings = Settings::load_from_path(&settings_path).unwrap();
1344
1345        // Check env vars
1346        assert_eq!(settings.env.get("TEST_VAR").unwrap(), "test_value");
1347        assert_eq!(settings.env.get("CLAUDE_API_KEY").unwrap(), "test_api_key");
1348    }
1349
1350    #[test]
1351    fn load_warn_dedup_warns_once_per_distinct_failure() {
1352        let dedup = LoadWarnDedup::new();
1353        assert!(dedup.observe(Some("broken A")));
1354        assert!(!dedup.observe(Some("broken A")), "a repeat is silent");
1355        assert!(dedup.observe(Some("broken B")), "a new failure warns");
1356        assert!(!dedup.observe(Some("broken B")));
1357    }
1358
1359    #[test]
1360    fn load_warn_dedup_rewarns_after_a_successful_load() {
1361        // A long-lived process must warn again if the file is fixed and then
1362        // broken again, even with an identical error message.
1363        let dedup = LoadWarnDedup::new();
1364        assert!(dedup.observe(Some("broken")));
1365        assert!(!dedup.observe(None), "a success never warns");
1366        assert!(dedup.observe(Some("broken")));
1367    }
1368
1369    #[test]
1370    fn load_or_warn_default_warns_and_falls_back_when_settings_json_fails_to_parse() {
1371        // The shared loader every non-`Result` call site now uses (issue
1372        // #1744) — a missing settings.json resolves to defaults with no
1373        // warning (the ordinary case); this covers "file exists but doesn't
1374        // parse", which must warn rather than silently drop configuration.
1375        let guard = crate::drive::test_support::EnvGuard::take();
1376        let dir = guard.clear_credentials();
1377        let settings_dir = dir.path().join(".omni-dev");
1378        fs::create_dir_all(&settings_dir).unwrap();
1379        fs::write(settings_dir.join("settings.json"), "{not valid json").unwrap();
1380
1381        let logs = crate::test_support::capture_at(tracing::Level::WARN, || {
1382            let settings = Settings::load_or_warn_default();
1383            assert!(settings.env.is_empty());
1384            assert!(settings.profiles.is_empty());
1385            assert!(settings.drive.accounts.is_empty());
1386        });
1387        assert!(logs.contains("settings.json"), "{logs}");
1388    }
1389
1390    #[test]
1391    fn settings_get_env_var() {
1392        // Create a temporary directory (use current dir to avoid TMPDIR issues in tarpaulin)
1393        let temp_dir = {
1394            std::fs::create_dir_all("tmp").ok();
1395            TempDir::new_in("tmp").unwrap()
1396        };
1397        let settings_path = temp_dir.path().join("settings.json");
1398
1399        // Create a test settings file
1400        let settings_json = r#"{
1401            "env": {
1402                "TEST_VAR": "test_value",
1403                "CLAUDE_API_KEY": "test_api_key"
1404            }
1405        }"#;
1406        fs::write(&settings_path, settings_json).unwrap();
1407
1408        // Load settings
1409        let settings = Settings::load_from_path(&settings_path).unwrap();
1410
1411        // Set actual environment variable
1412        env::set_var("TEST_VAR_ENV", "env_value");
1413
1414        // Test precedence - env var should take precedence
1415        env::set_var("TEST_VAR", "env_override");
1416        assert_eq!(settings.get_env_var("TEST_VAR").unwrap(), "env_override");
1417
1418        // Test fallback to settings
1419        env::remove_var("TEST_VAR"); // Remove from environment
1420        assert_eq!(settings.get_env_var("TEST_VAR").unwrap(), "test_value");
1421
1422        // Test actual env var
1423        assert_eq!(settings.get_env_var("TEST_VAR_ENV").unwrap(), "env_value");
1424
1425        // Clean up
1426        env::remove_var("TEST_VAR_ENV");
1427    }
1428
1429    // ── profile resolution (pure: MapEnv raw env, explicit active profile) ──
1430
1431    #[test]
1432    fn resolve_no_profile_uses_base_env() {
1433        let settings = settings_with_profile();
1434        let raw = MapEnv::new();
1435        assert_eq!(
1436            settings
1437                .resolve_with(&raw, None, "ATLASSIAN_EMAIL")
1438                .as_deref(),
1439            Some("base@x.com")
1440        );
1441    }
1442
1443    #[test]
1444    fn resolve_active_profile_uses_profile_env() {
1445        let settings = settings_with_profile();
1446        let raw = MapEnv::new();
1447        assert_eq!(
1448            settings
1449                .resolve_with(&raw, Some("work"), "ATLASSIAN_EMAIL")
1450                .as_deref(),
1451            Some("me@work.com")
1452        );
1453    }
1454
1455    #[test]
1456    fn resolve_active_profile_does_not_consult_base() {
1457        // Isolated / AWS-faithful: a key present only in base is invisible while
1458        // a profile is active — fail loud rather than reuse a default token.
1459        let settings = settings_with_profile();
1460        let raw = MapEnv::new();
1461        assert_eq!(settings.resolve_with(&raw, Some("work"), "SHARED"), None);
1462    }
1463
1464    #[test]
1465    fn resolve_process_env_wins_over_profile_and_base() {
1466        let settings = settings_with_profile();
1467        let raw = MapEnv::new().with("ATLASSIAN_EMAIL", "cli@x.com");
1468        assert_eq!(
1469            settings
1470                .resolve_with(&raw, Some("work"), "ATLASSIAN_EMAIL")
1471                .as_deref(),
1472            Some("cli@x.com")
1473        );
1474        assert_eq!(
1475            settings
1476                .resolve_with(&raw, None, "ATLASSIAN_EMAIL")
1477                .as_deref(),
1478            Some("cli@x.com")
1479        );
1480    }
1481
1482    #[test]
1483    fn resolve_unknown_active_profile_yields_none() {
1484        // An unknown name never falls back to base; validation catches it at the
1485        // CLI boundary, but the resolver itself stays isolated.
1486        let settings = settings_with_profile();
1487        let raw = MapEnv::new();
1488        assert_eq!(
1489            settings.resolve_with(&raw, Some("nope"), "ATLASSIAN_EMAIL"),
1490            None
1491        );
1492    }
1493
1494    // ── pair resolution (issue #2006: NAME / NAME_FILE per layer) ──
1495
1496    #[test]
1497    fn upsert_of_a_secret_replaces_its_file_companion_in_the_same_map() {
1498        let (_tmp, path) = temp_settings_path();
1499        fs::create_dir_all(path.parent().unwrap()).unwrap();
1500        fs::write(
1501            &path,
1502            r#"{"env": {"DATADOG_API_KEY_FILE": "/run/k", "OTHER_FILE": "/keep"},
1503                "profiles": {"work": {"env": {"DATADOG_API_KEY_FILE": "/w"}}}}"#,
1504        )
1505        .unwrap();
1506
1507        Settings::upsert_env_vars_in(&path, None, &[("DATADOG_API_KEY", "v")]).unwrap();
1508
1509        let val = read_json(&path);
1510        assert_eq!(val["env"]["DATADOG_API_KEY"], "v");
1511        assert!(val["env"].get("DATADOG_API_KEY_FILE").is_none());
1512        // Non-secret keys and other maps are untouched.
1513        assert_eq!(val["env"]["OTHER_FILE"], "/keep");
1514        assert_eq!(val["profiles"]["work"]["env"]["DATADOG_API_KEY_FILE"], "/w");
1515    }
1516
1517    #[test]
1518    fn remove_of_a_secret_also_removes_its_file_companion() {
1519        let (_tmp, path) = temp_settings_path();
1520        fs::create_dir_all(path.parent().unwrap()).unwrap();
1521        fs::write(&path, r#"{"env": {"DATADOG_API_KEY_FILE": "/run/k"}}"#).unwrap();
1522
1523        assert!(Settings::remove_env_vars_in(&path, None, &["DATADOG_API_KEY"]).unwrap());
1524        assert!(read_json(&path)["env"]
1525            .get("DATADOG_API_KEY_FILE")
1526            .is_none());
1527    }
1528
1529    fn settings_with_env(base: &[(&str, &str)], work: &[(&str, &str)]) -> Settings {
1530        let map = |pairs: &[(&str, &str)]| {
1531            pairs
1532                .iter()
1533                .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
1534                .collect::<HashMap<_, _>>()
1535        };
1536        let mut profiles = HashMap::new();
1537        profiles.insert("work".to_string(), Profile { env: map(work) });
1538        Settings {
1539            env: map(base),
1540            profiles,
1541            ..Settings::default()
1542        }
1543    }
1544
1545    #[test]
1546    fn resolve_pair_takes_the_process_env_pair_over_settings() {
1547        // `NAME_FILE` in the process env beats `NAME` in settings.json: the
1548        // layers never mix, so this is not a conflict.
1549        let settings = settings_with_env(&[("K", "from-settings")], &[]);
1550        let raw = MapEnv::new().with("K_FILE", "/run/k");
1551        assert_eq!(
1552            settings.resolve_pair_with(&raw, None, "K", "K_FILE"),
1553            (None, Some("/run/k".to_string()))
1554        );
1555    }
1556
1557    #[test]
1558    fn resolve_pair_falls_back_to_one_settings_layer_as_a_pair() {
1559        let settings = settings_with_env(&[("K", "v"), ("K_FILE", "/f")], &[("K_FILE", "/w")]);
1560        let raw = MapEnv::new();
1561        // Both in the base layer come back together (the resolver rejects it).
1562        assert_eq!(
1563            settings.resolve_pair_with(&raw, None, "K", "K_FILE"),
1564            (Some("v".to_string()), Some("/f".to_string()))
1565        );
1566        // A profile replaces the base layer entirely.
1567        assert_eq!(
1568            settings.resolve_pair_with(&raw, Some("work"), "K", "K_FILE"),
1569            (None, Some("/w".to_string()))
1570        );
1571        assert_eq!(
1572            settings.resolve_pair_with(&raw, Some("nope"), "K", "K_FILE"),
1573            (None, None)
1574        );
1575    }
1576
1577    #[test]
1578    fn resolve_pair_empty_process_env_value_neutralises_settings() {
1579        // Matches `resolve_with`: an exported `K=` shadows the settings layer.
1580        let settings = settings_with_env(&[("K", "from-settings")], &[]);
1581        let raw = MapEnv::new().with("K", "");
1582        assert_eq!(
1583            settings.resolve_pair_with(&raw, None, "K", "K_FILE"),
1584            (Some(String::new()), None)
1585        );
1586    }
1587
1588    #[test]
1589    fn secret_resolver_over_settings_layers() {
1590        use crate::utils::secret_env::{secret_var, SecretEnvError};
1591        let dir = tempfile::tempdir().unwrap();
1592        let path = dir.path().join("key");
1593        std::fs::write(&path, "from-file\n").unwrap();
1594        #[cfg(unix)]
1595        {
1596            use std::os::unix::fs::PermissionsExt;
1597            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
1598        }
1599        let path = path.to_str().unwrap();
1600
1601        struct Layered<'a>(&'a Settings, MapEnv);
1602        impl EnvSource for Layered<'_> {
1603            fn var(&self, key: &str) -> Option<String> {
1604                self.0.resolve_with(&self.1, None, key)
1605            }
1606            fn var_pair(&self, key: &str, file_key: &str) -> (Option<String>, Option<String>) {
1607                self.0.resolve_pair_with(&self.1, None, key, file_key)
1608            }
1609        }
1610
1611        // Env file overrides a settings value.
1612        let settings = settings_with_env(&[("DATADOG_API_KEY", "from-settings")], &[]);
1613        let env = Layered(&settings, MapEnv::new().with("DATADOG_API_KEY_FILE", path));
1614        let got = secret_var(&env, "DATADOG_API_KEY").unwrap().unwrap();
1615        assert_eq!(got.expose_secret(), "from-file");
1616
1617        // A settings `_FILE` works on its own.
1618        let settings = settings_with_env(&[("DATADOG_API_KEY_FILE", path)], &[]);
1619        let env = Layered(&settings, MapEnv::new());
1620        let got = secret_var(&env, "DATADOG_API_KEY").unwrap().unwrap();
1621        assert_eq!(got.expose_secret(), "from-file");
1622
1623        // Both in settings is a conflict.
1624        let settings = settings_with_env(
1625            &[("DATADOG_API_KEY", "v"), ("DATADOG_API_KEY_FILE", path)],
1626            &[],
1627        );
1628        let env = Layered(&settings, MapEnv::new());
1629        assert!(matches!(
1630            secret_var(&env, "DATADOG_API_KEY").unwrap_err(),
1631            SecretEnvError::Conflict { .. }
1632        ));
1633        // secret_var only ever reads var_pair; var itself must still resolve
1634        // through the same settings/profile chain for other EnvSource callers.
1635        assert_eq!(env.var("DATADOG_API_KEY"), Some("v".to_string()));
1636    }
1637
1638    #[test]
1639    fn settings_env_ref_var_falls_back_to_the_settings_layer() {
1640        // SettingsEnvRef::var is EnvSource's required method; secret_var and
1641        // secret_var_is_set (its only production callers) read var_pair alone,
1642        // so this exercises it directly against the same fallback chain
1643        // SettingsEnv::var uses.
1644        let settings = settings_with_env(
1645            &[("OMNI_DEV_TEST_SETTINGS_ENV_REF_K", "from-settings")],
1646            &[],
1647        );
1648        let env_ref = settings.env_source();
1649        assert_eq!(
1650            env_ref.var("OMNI_DEV_TEST_SETTINGS_ENV_REF_K"),
1651            Some("from-settings".to_string())
1652        );
1653        assert_eq!(env_ref.var("OMNI_DEV_TEST_SETTINGS_ENV_REF_MISSING"), None);
1654    }
1655
1656    // ── sourced resolution (issue #1143: provenance for warnings) ──
1657
1658    #[test]
1659    fn resolve_with_source_process_env_is_process_env() {
1660        let settings = settings_with_profile();
1661        let raw = MapEnv::new().with("ATLASSIAN_EMAIL", "cli@x.com");
1662        assert_eq!(
1663            settings.resolve_with_source(&raw, None, "ATLASSIAN_EMAIL"),
1664            Some(("cli@x.com".to_string(), EnvValueSource::ProcessEnv))
1665        );
1666    }
1667
1668    #[test]
1669    fn resolve_with_source_base_env_is_settings_env() {
1670        let settings = settings_with_profile();
1671        let raw = MapEnv::new();
1672        assert_eq!(
1673            settings.resolve_with_source(&raw, None, "ATLASSIAN_EMAIL"),
1674            Some(("base@x.com".to_string(), EnvValueSource::SettingsEnv))
1675        );
1676    }
1677
1678    #[test]
1679    fn resolve_with_source_profile_env_names_profile() {
1680        let settings = settings_with_profile();
1681        let raw = MapEnv::new();
1682        assert_eq!(
1683            settings.resolve_with_source(&raw, Some("work"), "ATLASSIAN_EMAIL"),
1684            Some((
1685                "me@work.com".to_string(),
1686                EnvValueSource::SettingsProfile("work".to_string())
1687            ))
1688        );
1689    }
1690
1691    #[test]
1692    fn resolve_with_source_missing_key_is_none() {
1693        let settings = settings_with_profile();
1694        let raw = MapEnv::new();
1695        assert_eq!(settings.resolve_with_source(&raw, None, "MISSING"), None);
1696    }
1697
1698    #[test]
1699    fn env_value_source_display_names_each_layer() {
1700        assert_eq!(EnvValueSource::CliFlag.to_string(), "command-line flag");
1701        assert_eq!(
1702            EnvValueSource::ProcessEnv.to_string(),
1703            "process environment variable (e.g. a shell export)"
1704        );
1705        assert_eq!(
1706            EnvValueSource::SettingsEnv.to_string(),
1707            "the env map in $HOME/.omni-dev/settings.json"
1708        );
1709        assert_eq!(
1710            EnvValueSource::SettingsProfile("work".to_string()).to_string(),
1711            "the profile 'work' env map in $HOME/.omni-dev/settings.json"
1712        );
1713    }
1714
1715    #[test]
1716    fn active_profile_from_reads_and_trims_empty() {
1717        assert_eq!(active_profile_from(&MapEnv::new()), None);
1718        assert_eq!(
1719            active_profile_from(&MapEnv::new().with(PROFILE_ENV_VAR, "")),
1720            None
1721        );
1722        assert_eq!(
1723            active_profile_from(&MapEnv::new().with(PROFILE_ENV_VAR, "work")).as_deref(),
1724            Some("work")
1725        );
1726    }
1727
1728    #[test]
1729    fn profile_suffix_names_profile_or_is_empty() {
1730        assert_eq!(profile_suffix(None), "");
1731        assert_eq!(profile_suffix(Some("work")), " (profile 'work')");
1732    }
1733
1734    #[test]
1735    fn validate_profile_accepts_known() {
1736        assert!(settings_with_profile().validate_profile("work").is_ok());
1737    }
1738
1739    #[test]
1740    fn validate_profile_rejects_unknown_and_lists_sorted() {
1741        let mut settings = settings_with_profile();
1742        settings
1743            .profiles
1744            .insert("personal".to_string(), Profile::default());
1745        let err = settings.validate_profile("wrok").unwrap_err().to_string();
1746        assert_eq!(
1747            err,
1748            "unknown profile 'wrok'; known profiles: personal, work"
1749        );
1750    }
1751
1752    #[test]
1753    fn validate_profile_reports_none_when_empty() {
1754        let settings = Settings::default();
1755        let err = settings.validate_profile("work").unwrap_err().to_string();
1756        assert_eq!(err, "unknown profile 'work'; known profiles: (none)");
1757    }
1758
1759    #[test]
1760    fn settings_parse_profiles_from_json() {
1761        let json = r#"{
1762            "env": { "BASE": "b" },
1763            "profiles": {
1764                "work": { "env": { "ATLASSIAN_EMAIL": "me@work.com" } }
1765            }
1766        }"#;
1767        let settings: Settings = serde_json::from_str(json).unwrap();
1768        assert_eq!(settings.env.get("BASE").unwrap(), "b");
1769        assert_eq!(
1770            settings
1771                .profiles
1772                .get("work")
1773                .unwrap()
1774                .env
1775                .get("ATLASSIAN_EMAIL")
1776                .unwrap(),
1777            "me@work.com"
1778        );
1779    }
1780
1781    #[test]
1782    fn settings_without_profiles_key_defaults_empty() {
1783        let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
1784        assert!(settings.profiles.is_empty());
1785    }
1786
1787    #[test]
1788    fn settings_parse_mcp_section_from_json() {
1789        let json = r#"{
1790            "mcp": {
1791                "default_model": "claude-sonnet-4-6",
1792                "log_level": "info",
1793                "max_response_bytes": 204800
1794            }
1795        }"#;
1796        let settings: Settings = serde_json::from_str(json).unwrap();
1797        assert_eq!(
1798            settings.mcp.default_model.as_deref(),
1799            Some("claude-sonnet-4-6")
1800        );
1801        assert_eq!(settings.mcp.log_level.as_deref(), Some("info"));
1802        assert_eq!(settings.mcp.max_response_bytes, Some(204_800));
1803    }
1804
1805    #[test]
1806    fn settings_without_mcp_key_defaults_all_none() {
1807        // An absent `mcp` block must leave every field unset so callers fall
1808        // back to the built-in defaults (byte-for-byte behaviour, issue #620).
1809        let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
1810        assert!(settings.mcp.default_model.is_none());
1811        assert!(settings.mcp.log_level.is_none());
1812        assert!(settings.mcp.max_response_bytes.is_none());
1813    }
1814
1815    #[test]
1816    fn settings_mcp_partial_section_leaves_others_none() {
1817        // A block that sets only one field leaves the rest at their defaults.
1818        let settings: Settings =
1819            serde_json::from_str(r#"{ "mcp": { "log_level": "debug" } }"#).unwrap();
1820        assert_eq!(settings.mcp.log_level.as_deref(), Some("debug"));
1821        assert!(settings.mcp.default_model.is_none());
1822        assert!(settings.mcp.max_response_bytes.is_none());
1823    }
1824
1825    #[test]
1826    fn settings_parse_gmail_section_from_json() {
1827        let json = r#"{
1828            "gmail": {
1829                "default_account": "work",
1830                "accounts": {
1831                    "work": {
1832                        "client_id": "id",
1833                        "client_secret": "secret",
1834                        "refresh_token": "token",
1835                        "scope": "https://www.googleapis.com/auth/gmail.modify",
1836                        "email_address": "alice@work.com"
1837                    }
1838                }
1839            }
1840        }"#;
1841        let settings: Settings = serde_json::from_str(json).unwrap();
1842        assert_eq!(settings.gmail.default_account.as_deref(), Some("work"));
1843        let account = settings.gmail.accounts.get("work").unwrap();
1844        assert_eq!(account.client_id.as_deref(), Some("id"));
1845        assert_eq!(account.client_secret.as_deref(), Some("secret"));
1846        assert_eq!(account.refresh_token.as_deref(), Some("token"));
1847        assert_eq!(
1848            account.scope.as_deref(),
1849            Some("https://www.googleapis.com/auth/gmail.modify")
1850        );
1851        assert_eq!(account.email_address.as_deref(), Some("alice@work.com"));
1852    }
1853
1854    #[test]
1855    fn settings_without_gmail_key_defaults_empty() {
1856        let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
1857        assert!(settings.gmail.default_account.is_none());
1858        assert!(settings.gmail.accounts.is_empty());
1859    }
1860
1861    #[test]
1862    fn settings_parse_drive_section_from_json() {
1863        let json = r#"{
1864            "drive": {
1865                "default_account": "work",
1866                "accounts": {
1867                    "work": {
1868                        "client_id": "id",
1869                        "client_secret": "secret",
1870                        "refresh_token": "token",
1871                        "scope": "https://www.googleapis.com/auth/drive.readonly",
1872                        "email_address": "alice@work.com"
1873                    }
1874                }
1875            }
1876        }"#;
1877        let settings: Settings = serde_json::from_str(json).unwrap();
1878        assert_eq!(settings.drive.default_account.as_deref(), Some("work"));
1879        let account = settings.drive.accounts.get("work").unwrap();
1880        assert_eq!(account.client_id.as_deref(), Some("id"));
1881        assert_eq!(account.client_secret.as_deref(), Some("secret"));
1882        assert_eq!(account.refresh_token.as_deref(), Some("token"));
1883        assert_eq!(
1884            account.scope.as_deref(),
1885            Some("https://www.googleapis.com/auth/drive.readonly")
1886        );
1887        assert_eq!(account.email_address.as_deref(), Some("alice@work.com"));
1888    }
1889
1890    #[test]
1891    fn settings_without_drive_key_defaults_empty() {
1892        let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
1893        assert!(settings.drive.default_account.is_none());
1894        assert!(settings.drive.accounts.is_empty());
1895    }
1896
1897    #[test]
1898    fn settings_parse_lease_section_from_json() {
1899        let json = r#"{
1900            "lease": {
1901                "default_expiry_minutes": 45,
1902                "backup_dir": "/tmp/drive-backups",
1903                "biometrics_only": true,
1904                "allow_headless": true
1905            }
1906        }"#;
1907        let settings: Settings = serde_json::from_str(json).unwrap();
1908        assert_eq!(settings.lease.default_expiry_minutes, Some(45));
1909        assert_eq!(
1910            settings.lease.backup_dir.as_deref(),
1911            Some(Path::new("/tmp/drive-backups"))
1912        );
1913        assert!(settings.lease.biometrics_only);
1914        assert!(settings.lease.allow_headless);
1915    }
1916
1917    #[test]
1918    fn settings_without_lease_key_defaults_all_unset() {
1919        // An absent `lease` block must leave every field at its built-in
1920        // default so `drive lease` behaves byte-for-byte as before (issue
1921        // #1677).
1922        let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
1923        assert!(settings.lease.default_expiry_minutes.is_none());
1924        assert!(settings.lease.backup_dir.is_none());
1925        assert!(!settings.lease.biometrics_only);
1926        assert!(!settings.lease.allow_headless);
1927    }
1928
1929    #[test]
1930    fn settings_lease_partial_section_leaves_others_default() {
1931        let settings: Settings =
1932            serde_json::from_str(r#"{ "lease": { "biometrics_only": true } }"#).unwrap();
1933        assert!(settings.lease.biometrics_only);
1934        assert!(settings.lease.default_expiry_minutes.is_none());
1935        assert!(settings.lease.backup_dir.is_none());
1936        assert!(!settings.lease.allow_headless);
1937    }
1938
1939    // ── free get_env_var seam (pure: injected raw env + lazy settings loader) ──
1940
1941    #[test]
1942    fn get_env_var_with_returns_raw_hit_without_loading() {
1943        let env = MapEnv::new().with("K", "v");
1944        let value = get_env_var_with(&env, || panic!("must not load settings"), "K").unwrap();
1945        assert_eq!(value, "v");
1946    }
1947
1948    #[test]
1949    fn get_env_var_with_falls_back_to_base_settings() {
1950        let settings = settings_with_profile();
1951        let env = MapEnv::new();
1952        let value = get_env_var_with(&env, || Ok(settings), "ATLASSIAN_EMAIL").unwrap();
1953        assert_eq!(value, "base@x.com");
1954    }
1955
1956    #[test]
1957    fn get_env_var_with_honours_active_profile() {
1958        let settings = settings_with_profile();
1959        let env = MapEnv::new().with(PROFILE_ENV_VAR, "work");
1960        let value = get_env_var_with(&env, || Ok(settings), "ATLASSIAN_EMAIL").unwrap();
1961        assert_eq!(value, "me@work.com");
1962    }
1963
1964    #[test]
1965    fn get_env_var_with_missing_key_is_not_found() {
1966        let env = MapEnv::new();
1967        let err = get_env_var_with(&env, || Ok(Settings::default()), "MISSING")
1968            .unwrap_err()
1969            .to_string();
1970        assert!(err.contains("Environment variable not found: MISSING"));
1971    }
1972
1973    #[test]
1974    fn get_env_var_with_load_error_maps_to_not_found() {
1975        let env = MapEnv::new();
1976        let err =
1977            get_env_var_with(&env, || Err(anyhow::anyhow!("disk boom")), "MISSING").unwrap_err();
1978        // The load failure is the top-level context; the not-found error is its
1979        // source. The full chain (`{:#}`) carries both.
1980        assert_eq!(err.to_string(), "disk boom");
1981        let chain = format!("{err:#}");
1982        assert!(chain.contains("Environment variable not found: MISSING"));
1983    }
1984
1985    // ── sourced get_env_var seam (issue #1143) ──
1986
1987    #[test]
1988    fn get_env_var_sourced_with_raw_hit_is_process_env() {
1989        let env = MapEnv::new().with("K", "v");
1990        let resolved =
1991            get_env_var_sourced_with(&env, || panic!("must not load settings"), false, "K")
1992                .unwrap();
1993        assert_eq!(resolved, ("v".to_string(), EnvValueSource::ProcessEnv));
1994    }
1995
1996    #[test]
1997    fn get_env_var_sourced_with_flag_export_is_cli_flag() {
1998        let env = MapEnv::new().with("K", "true");
1999        let resolved =
2000            get_env_var_sourced_with(&env, || panic!("must not load settings"), true, "K").unwrap();
2001        assert_eq!(resolved, ("true".to_string(), EnvValueSource::CliFlag));
2002    }
2003
2004    #[test]
2005    fn get_env_var_sourced_with_falls_back_to_settings_sources() {
2006        let settings = settings_with_profile();
2007        let env = MapEnv::new();
2008        let resolved =
2009            get_env_var_sourced_with(&env, || Ok(settings), false, "ATLASSIAN_EMAIL").unwrap();
2010        assert_eq!(
2011            resolved,
2012            ("base@x.com".to_string(), EnvValueSource::SettingsEnv)
2013        );
2014
2015        let settings = settings_with_profile();
2016        let env = MapEnv::new().with(PROFILE_ENV_VAR, "work");
2017        let resolved =
2018            get_env_var_sourced_with(&env, || Ok(settings), false, "ATLASSIAN_EMAIL").unwrap();
2019        assert_eq!(
2020            resolved,
2021            (
2022                "me@work.com".to_string(),
2023                EnvValueSource::SettingsProfile("work".to_string())
2024            )
2025        );
2026    }
2027
2028    #[test]
2029    fn cli_flag_export_registry_roundtrip() {
2030        // Unique key: the registry is a process-global, additive-only set, so
2031        // this test must not share keys with other tests (or production code).
2032        const KEY: &str = "OMNI_DEV_TEST_1143_REGISTRY_ROUNDTRIP";
2033        assert!(!exported_by_cli_flag(KEY));
2034        note_cli_flag_export(KEY);
2035        assert!(exported_by_cli_flag(KEY));
2036    }
2037
2038    // ── env-write helpers (injected paths, no HOME mutation — issue #1030) ──
2039
2040    /// Creates a tempdir under `tmp/` (avoids TMPDIR issues in tarpaulin) and
2041    /// returns it with a `<dir>/.omni-dev/settings.json` path inside it.
2042    fn temp_settings_path() -> (TempDir, std::path::PathBuf) {
2043        let temp_dir = {
2044            std::fs::create_dir_all("tmp").ok();
2045            TempDir::new_in("tmp").unwrap()
2046        };
2047        let path = temp_dir.path().join(".omni-dev").join("settings.json");
2048        (temp_dir, path)
2049    }
2050
2051    fn read_json(path: &Path) -> serde_json::Value {
2052        serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap()
2053    }
2054
2055    #[test]
2056    fn upsert_env_vars_creates_file_and_dir_with_secure_permissions() {
2057        let (_tmp, path) = temp_settings_path();
2058
2059        Settings::upsert_env_vars(&path, &[("A_KEY", "a"), ("B_KEY", "b")]).unwrap();
2060
2061        let val = read_json(&path);
2062        assert_eq!(val["env"]["A_KEY"], "a");
2063        assert_eq!(val["env"]["B_KEY"], "b");
2064
2065        // Credential store hardening (issue #1128): dir 0700, file 0600.
2066        #[cfg(unix)]
2067        {
2068            use std::os::unix::fs::PermissionsExt;
2069            let dir_mode = fs::metadata(path.parent().unwrap())
2070                .unwrap()
2071                .permissions()
2072                .mode();
2073            assert_eq!(dir_mode & 0o777, 0o700);
2074            let file_mode = fs::metadata(&path).unwrap().permissions().mode();
2075            assert_eq!(file_mode & 0o777, 0o600);
2076        }
2077    }
2078
2079    #[test]
2080    fn upsert_env_vars_merges_and_preserves_unknown_fields() {
2081        let (_tmp, path) = temp_settings_path();
2082        fs::create_dir_all(path.parent().unwrap()).unwrap();
2083        fs::write(&path, r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#).unwrap();
2084
2085        Settings::upsert_env_vars(&path, &[("A_KEY", "new")]).unwrap();
2086
2087        let val = read_json(&path);
2088        assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
2089        assert_eq!(val["extra"], true);
2090        assert_eq!(val["env"]["A_KEY"], "new");
2091    }
2092
2093    #[test]
2094    fn upsert_env_vars_replaces_non_object_env() {
2095        let (_tmp, path) = temp_settings_path();
2096        fs::create_dir_all(path.parent().unwrap()).unwrap();
2097        fs::write(&path, r#"{"env": "not-an-object"}"#).unwrap();
2098
2099        Settings::upsert_env_vars(&path, &[("A_KEY", "a")]).unwrap();
2100
2101        assert_eq!(read_json(&path)["env"]["A_KEY"], "a");
2102    }
2103
2104    #[cfg(unix)]
2105    #[test]
2106    fn upsert_env_vars_retightens_loose_permissions() {
2107        use std::os::unix::fs::PermissionsExt;
2108
2109        let (_tmp, path) = temp_settings_path();
2110        fs::create_dir_all(path.parent().unwrap()).unwrap();
2111        fs::write(&path, r#"{"env": {}}"#).unwrap();
2112        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
2113
2114        Settings::upsert_env_vars(&path, &[("A_KEY", "a")]).unwrap();
2115
2116        let file_mode = fs::metadata(&path).unwrap().permissions().mode();
2117        assert_eq!(file_mode & 0o777, 0o600);
2118    }
2119
2120    #[test]
2121    fn remove_env_vars_removes_listed_keys_and_preserves_rest() {
2122        let (_tmp, path) = temp_settings_path();
2123        fs::create_dir_all(path.parent().unwrap()).unwrap();
2124        fs::write(
2125            &path,
2126            r#"{"env": {"A_KEY": "a", "B_KEY": "b", "OTHER_KEY": "keep"}, "extra": true}"#,
2127        )
2128        .unwrap();
2129
2130        let removed = Settings::remove_env_vars(&path, &["A_KEY", "B_KEY", "ABSENT"]).unwrap();
2131        assert!(removed);
2132
2133        let val = read_json(&path);
2134        assert!(val["env"].get("A_KEY").is_none());
2135        assert!(val["env"].get("B_KEY").is_none());
2136        assert_eq!(val["env"]["OTHER_KEY"], "keep");
2137        assert_eq!(val["extra"], true);
2138    }
2139
2140    #[test]
2141    fn remove_env_vars_false_when_file_missing() {
2142        let (_tmp, path) = temp_settings_path();
2143        assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
2144        assert!(!path.exists());
2145    }
2146
2147    #[test]
2148    fn remove_env_vars_false_when_env_missing_or_not_an_object() {
2149        let (_tmp, path) = temp_settings_path();
2150        fs::create_dir_all(path.parent().unwrap()).unwrap();
2151
2152        // No "env" key at all.
2153        fs::write(&path, r#"{"extra": true}"#).unwrap();
2154        assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
2155
2156        // "env" present but not an object.
2157        fs::write(&path, r#"{"env": "not-an-object"}"#).unwrap();
2158        assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
2159    }
2160
2161    #[test]
2162    fn upsert_env_vars_bare_filename_skips_dir_creation() {
2163        // A bare relative filename has an empty parent — the dir-creation
2164        // branch must be skipped, not fail on `create_dir_all("")`.
2165        let name = format!("tmp-upsert-bare-{}.json", std::process::id());
2166        let path = Path::new(&name);
2167
2168        Settings::upsert_env_vars(path, &[("A_KEY", "a")]).unwrap();
2169
2170        assert_eq!(read_json(path)["env"]["A_KEY"], "a");
2171        fs::remove_file(path).unwrap();
2172    }
2173
2174    #[test]
2175    fn remove_env_vars_false_when_keys_absent_leaves_file_untouched() {
2176        let (_tmp, path) = temp_settings_path();
2177        fs::create_dir_all(path.parent().unwrap()).unwrap();
2178        let original = r#"{"env": {"OTHER_KEY": "keep"}}"#;
2179        fs::write(&path, original).unwrap();
2180
2181        let removed = Settings::remove_env_vars(&path, &["A_KEY"]).unwrap();
2182        assert!(!removed);
2183        // Not rewritten: the raw bytes are exactly as written.
2184        assert_eq!(fs::read_to_string(&path).unwrap(), original);
2185    }
2186
2187    // ── profile-targeted env writes (issue #1116) ────────────────────
2188
2189    #[test]
2190    fn upsert_env_vars_in_profile_creates_profile_env() {
2191        let (_tmp, path) = temp_settings_path();
2192
2193        Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
2194
2195        let val = read_json(&path);
2196        assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "a");
2197        // The base env map is not touched (read-side isolation mirrored).
2198        assert!(val.get("env").is_none());
2199
2200        // Credential store hardening (issue #1128) applies to profile
2201        // writes too: dir 0700, file 0600.
2202        #[cfg(unix)]
2203        {
2204            use std::os::unix::fs::PermissionsExt;
2205            let dir_mode = fs::metadata(path.parent().unwrap())
2206                .unwrap()
2207                .permissions()
2208                .mode();
2209            assert_eq!(dir_mode & 0o777, 0o700);
2210            let file_mode = fs::metadata(&path).unwrap().permissions().mode();
2211            assert_eq!(file_mode & 0o777, 0o600);
2212        }
2213    }
2214
2215    #[test]
2216    fn upsert_env_vars_in_profile_preserves_base_and_other_profiles() {
2217        let (_tmp, path) = temp_settings_path();
2218        fs::create_dir_all(path.parent().unwrap()).unwrap();
2219        fs::write(
2220            &path,
2221            r#"{
2222                "env": {"SHARED": "base"},
2223                "profiles": {
2224                    "work": {"env": {"OLD": "keep"}},
2225                    "home": {"env": {"SHARED": "home"}}
2226                },
2227                "extra": true
2228            }"#,
2229        )
2230        .unwrap();
2231
2232        Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
2233
2234        let val = read_json(&path);
2235        assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "a");
2236        assert_eq!(val["profiles"]["work"]["env"]["OLD"], "keep");
2237        assert_eq!(val["profiles"]["home"]["env"]["SHARED"], "home");
2238        assert_eq!(val["env"]["SHARED"], "base");
2239        assert_eq!(val["extra"], true);
2240    }
2241
2242    #[test]
2243    fn upsert_env_vars_in_profile_replaces_non_object_nodes() {
2244        let (_tmp, path) = temp_settings_path();
2245        fs::create_dir_all(path.parent().unwrap()).unwrap();
2246
2247        // "profiles" itself is not an object.
2248        fs::write(&path, r#"{"profiles": "bogus"}"#).unwrap();
2249        Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
2250        assert_eq!(read_json(&path)["profiles"]["work"]["env"]["A_KEY"], "a");
2251
2252        // The profile node is not an object.
2253        fs::write(&path, r#"{"profiles": {"work": []}}"#).unwrap();
2254        Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
2255        assert_eq!(read_json(&path)["profiles"]["work"]["env"]["A_KEY"], "a");
2256    }
2257
2258    #[test]
2259    fn remove_env_vars_in_profile_removes_only_profile_keys() {
2260        let (_tmp, path) = temp_settings_path();
2261        fs::create_dir_all(path.parent().unwrap()).unwrap();
2262        fs::write(
2263            &path,
2264            r#"{
2265                "env": {"A_KEY": "base"},
2266                "profiles": {"work": {"env": {"A_KEY": "work", "OTHER": "keep"}}}
2267            }"#,
2268        )
2269        .unwrap();
2270
2271        let removed = Settings::remove_env_vars_in(&path, Some("work"), &["A_KEY"]).unwrap();
2272        assert!(removed);
2273
2274        let val = read_json(&path);
2275        assert!(val["profiles"]["work"]["env"].get("A_KEY").is_none());
2276        assert_eq!(val["profiles"]["work"]["env"]["OTHER"], "keep");
2277        // The base copy of the same key survives.
2278        assert_eq!(val["env"]["A_KEY"], "base");
2279    }
2280
2281    #[test]
2282    fn remove_env_vars_in_profile_false_when_profile_missing() {
2283        let (_tmp, path) = temp_settings_path();
2284        fs::create_dir_all(path.parent().unwrap()).unwrap();
2285        let original = r#"{"env": {"A_KEY": "base"}}"#;
2286        fs::write(&path, original).unwrap();
2287
2288        let removed = Settings::remove_env_vars_in(&path, Some("work"), &["A_KEY"]).unwrap();
2289        assert!(!removed);
2290        // Not rewritten: the raw bytes are exactly as written.
2291        assert_eq!(fs::read_to_string(&path).unwrap(), original);
2292    }
2293
2294    #[test]
2295    fn remove_env_vars_in_none_targets_base_env() {
2296        let (_tmp, path) = temp_settings_path();
2297        fs::create_dir_all(path.parent().unwrap()).unwrap();
2298        fs::write(
2299            &path,
2300            r#"{"env": {"A_KEY": "base"}, "profiles": {"work": {"env": {"A_KEY": "work"}}}}"#,
2301        )
2302        .unwrap();
2303
2304        let removed = Settings::remove_env_vars_in(&path, None, &["A_KEY"]).unwrap();
2305        assert!(removed);
2306
2307        let val = read_json(&path);
2308        assert!(val["env"].get("A_KEY").is_none());
2309        assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "work");
2310    }
2311
2312    // ── generalized path-segment walkers (issue #1500) ────────────────
2313
2314    #[test]
2315    fn ensure_object_at_creates_nested_path_at_arbitrary_depth() {
2316        let mut root = serde_json::json!({});
2317        {
2318            let map = ensure_object_at(&mut root, &["gmail", "accounts", "work"]).unwrap();
2319            map.insert("client_id".to_string(), serde_json::json!("id"));
2320        }
2321        assert_eq!(root["gmail"]["accounts"]["work"]["client_id"], "id");
2322    }
2323
2324    #[test]
2325    fn ensure_object_at_replaces_non_object_nodes_along_path() {
2326        let mut root = serde_json::json!({"gmail": "bogus"});
2327        {
2328            let map = ensure_object_at(&mut root, &["gmail", "accounts", "work"]).unwrap();
2329            map.insert("client_id".to_string(), serde_json::json!("id"));
2330        }
2331        assert_eq!(root["gmail"]["accounts"]["work"]["client_id"], "id");
2332    }
2333
2334    #[test]
2335    fn object_at_mut_none_when_any_segment_absent() {
2336        let mut root = serde_json::json!({"gmail": {"accounts": {}}});
2337        assert!(object_at_mut(&mut root, &["gmail", "accounts", "work"]).is_none());
2338        assert!(object_at_mut(&mut root, &["missing", "accounts"]).is_none());
2339    }
2340
2341    // ── gmail account writes (issue #1500) ─────────────────────────────
2342
2343    #[test]
2344    fn upsert_gmail_account_creates_nested_path_and_preserves_siblings() {
2345        let (_tmp, path) = temp_settings_path();
2346        fs::create_dir_all(path.parent().unwrap()).unwrap();
2347        fs::write(
2348            &path,
2349            r#"{"env": {"SHARED": "base"}, "gmail": {"accounts": {"personal": {"client_id": "keep"}}}, "extra": true}"#,
2350        )
2351        .unwrap();
2352
2353        Settings::upsert_gmail_account(
2354            &path,
2355            "work",
2356            &[
2357                ("client_id", serde_json::Value::String("id".to_string())),
2358                (
2359                    "refresh_token",
2360                    serde_json::Value::String("token".to_string()),
2361                ),
2362            ],
2363        )
2364        .unwrap();
2365
2366        let val = read_json(&path);
2367        assert_eq!(val["gmail"]["accounts"]["work"]["client_id"], "id");
2368        assert_eq!(val["gmail"]["accounts"]["work"]["refresh_token"], "token");
2369        assert_eq!(val["gmail"]["accounts"]["personal"]["client_id"], "keep");
2370        assert_eq!(val["env"]["SHARED"], "base");
2371        assert_eq!(val["extra"], true);
2372
2373        #[cfg(unix)]
2374        {
2375            use std::os::unix::fs::PermissionsExt;
2376            let file_mode = fs::metadata(&path).unwrap().permissions().mode();
2377            assert_eq!(file_mode & 0o777, 0o600);
2378        }
2379    }
2380
2381    /// Regression test for the PR #1528 review comment: writing a non-string
2382    /// field (e.g. `chrome_profile_from_email: bool`) through
2383    /// `upsert_gmail_account` must round-trip through `Settings::load()` —
2384    /// before the `vars` type was widened to `serde_json::Value`, this
2385    /// silently wrote the JSON string `"true"` into a `bool` field and broke
2386    /// parsing of the entire settings file on next load.
2387    #[test]
2388    fn upsert_gmail_account_writes_a_bool_value_that_round_trips_through_settings_load() {
2389        let (_tmp, path) = temp_settings_path();
2390
2391        Settings::upsert_gmail_account(
2392            &path,
2393            "work",
2394            &[("chrome_profile_from_email", serde_json::Value::Bool(true))],
2395        )
2396        .unwrap();
2397
2398        let val = read_json(&path);
2399        assert_eq!(
2400            val["gmail"]["accounts"]["work"]["chrome_profile_from_email"],
2401            true
2402        );
2403
2404        let settings = Settings::load_from_path(&path).unwrap();
2405        assert!(
2406            settings.gmail.accounts["work"].chrome_profile_from_email,
2407            "the bool field must deserialize back to `true`, not the string \"true\""
2408        );
2409    }
2410
2411    #[test]
2412    fn remove_gmail_account_true_when_present_false_when_absent() {
2413        let (_tmp, path) = temp_settings_path();
2414        fs::create_dir_all(path.parent().unwrap()).unwrap();
2415        fs::write(
2416            &path,
2417            r#"{"gmail": {"accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
2418        )
2419        .unwrap();
2420
2421        assert!(Settings::remove_gmail_account(&path, "work").unwrap());
2422        let val = read_json(&path);
2423        assert!(val["gmail"]["accounts"].get("work").is_none());
2424        assert_eq!(val["gmail"]["accounts"]["personal"]["client_id"], "keep");
2425
2426        assert!(!Settings::remove_gmail_account(&path, "work").unwrap());
2427    }
2428
2429    #[test]
2430    fn remove_gmail_account_clears_default_account_when_it_named_the_removed_account() {
2431        let (_tmp, path) = temp_settings_path();
2432        fs::create_dir_all(path.parent().unwrap()).unwrap();
2433        fs::write(
2434            &path,
2435            r#"{"gmail": {"default_account": "work", "accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
2436        )
2437        .unwrap();
2438
2439        assert!(Settings::remove_gmail_account(&path, "work").unwrap());
2440        let val = read_json(&path);
2441        assert!(val["gmail"].get("default_account").is_none());
2442        assert_eq!(val["gmail"]["accounts"]["personal"]["client_id"], "keep");
2443    }
2444
2445    #[test]
2446    fn remove_gmail_account_leaves_default_account_untouched_when_it_names_a_different_account() {
2447        let (_tmp, path) = temp_settings_path();
2448        fs::create_dir_all(path.parent().unwrap()).unwrap();
2449        fs::write(
2450            &path,
2451            r#"{"gmail": {"default_account": "personal", "accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
2452        )
2453        .unwrap();
2454
2455        assert!(Settings::remove_gmail_account(&path, "work").unwrap());
2456        let val = read_json(&path);
2457        assert_eq!(val["gmail"]["default_account"], "personal");
2458    }
2459
2460    #[test]
2461    fn remove_gmail_account_false_when_file_missing() {
2462        let (_tmp, path) = temp_settings_path();
2463        assert!(!Settings::remove_gmail_account(&path, "work").unwrap());
2464        assert!(!path.exists());
2465    }
2466
2467    #[test]
2468    fn set_gmail_default_account_sets_and_clears() {
2469        let (_tmp, path) = temp_settings_path();
2470
2471        Settings::set_gmail_default_account(&path, Some("work")).unwrap();
2472        assert_eq!(read_json(&path)["gmail"]["default_account"], "work");
2473
2474        Settings::set_gmail_default_account(&path, None).unwrap();
2475        assert!(read_json(&path)["gmail"].get("default_account").is_none());
2476    }
2477
2478    // ── drive account writes (issue #1522) ─────────────────────────────
2479
2480    #[test]
2481    fn upsert_drive_account_creates_nested_path_and_preserves_siblings() {
2482        let (_tmp, path) = temp_settings_path();
2483        fs::create_dir_all(path.parent().unwrap()).unwrap();
2484        fs::write(
2485            &path,
2486            r#"{"env": {"SHARED": "base"}, "drive": {"accounts": {"personal": {"client_id": "keep"}}}, "extra": true}"#,
2487        )
2488        .unwrap();
2489
2490        Settings::upsert_drive_account(
2491            &path,
2492            "work",
2493            &[
2494                ("client_id", serde_json::Value::String("id".to_string())),
2495                (
2496                    "refresh_token",
2497                    serde_json::Value::String("token".to_string()),
2498                ),
2499            ],
2500        )
2501        .unwrap();
2502
2503        let val = read_json(&path);
2504        assert_eq!(val["drive"]["accounts"]["work"]["client_id"], "id");
2505        assert_eq!(val["drive"]["accounts"]["work"]["refresh_token"], "token");
2506        assert_eq!(val["drive"]["accounts"]["personal"]["client_id"], "keep");
2507        assert_eq!(val["env"]["SHARED"], "base");
2508        assert_eq!(val["extra"], true);
2509
2510        #[cfg(unix)]
2511        {
2512            use std::os::unix::fs::PermissionsExt;
2513            let file_mode = fs::metadata(&path).unwrap().permissions().mode();
2514            assert_eq!(file_mode & 0o777, 0o600);
2515        }
2516    }
2517
2518    // ── named-account `_file` secrets (#2008) ────────────────────────────
2519
2520    /// A settings file whose `drive.accounts.work` names `refresh_token_file`
2521    /// (and optionally `client_secret_file`) under `dir`.
2522    fn settings_with_secret_files(
2523        path: &Path,
2524        client_secret_file: Option<&Path>,
2525        refresh_token_file: &Path,
2526    ) {
2527        let mut work = serde_json::json!({
2528            "client_id": "id",
2529            "refresh_token_file": refresh_token_file.to_str().unwrap(),
2530        });
2531        if let Some(file) = client_secret_file {
2532            work["client_secret_file"] = serde_json::json!(file.to_str().unwrap());
2533        }
2534        fs::create_dir_all(path.parent().unwrap()).unwrap();
2535        fs::write(
2536            path,
2537            serde_json::json!({ "drive": { "accounts": { "work": work } } }).to_string(),
2538        )
2539        .unwrap();
2540    }
2541
2542    #[test]
2543    fn account_secret_file_fields_deserialize() {
2544        let (_tmp, path) = temp_settings_path();
2545        settings_with_secret_files(&path, Some(Path::new("/s/cs")), Path::new("/s/rt"));
2546        let settings = Settings::load_from_path(&path).unwrap();
2547        let work = &settings.drive.accounts["work"];
2548        assert_eq!(work.client_secret_file.as_deref(), Some("/s/cs"));
2549        assert_eq!(work.refresh_token_file.as_deref(), Some("/s/rt"));
2550        assert!(work.client_secret.is_none());
2551    }
2552
2553    #[test]
2554    fn upsert_account_writes_a_secret_into_its_file_not_settings() {
2555        let (tmp, path) = temp_settings_path();
2556        let token_file = tmp.path().join("refresh-token");
2557        settings_with_secret_files(&path, None, &token_file);
2558
2559        Settings::upsert_drive_account(
2560            &path,
2561            "work",
2562            &[
2563                ("client_secret", serde_json::json!("plain-secret")),
2564                ("refresh_token", serde_json::json!("new-token")),
2565                ("scope", serde_json::json!("s")),
2566            ],
2567        )
2568        .unwrap();
2569
2570        let raw = fs::read_to_string(&path).unwrap();
2571        assert!(!raw.contains("new-token"), "{raw}");
2572        let val = read_json(&path);
2573        let work = &val["drive"]["accounts"]["work"];
2574        assert!(work.get("refresh_token").is_none());
2575        assert_eq!(work["refresh_token_file"], token_file.to_str().unwrap());
2576        // A secret field without a companion is written as before.
2577        assert_eq!(work["client_secret"], "plain-secret");
2578        assert_eq!(work["scope"], "s");
2579        assert_eq!(fs::read_to_string(&token_file).unwrap(), "new-token\n");
2580    }
2581
2582    #[test]
2583    fn upsert_account_drops_a_plain_field_that_would_conflict_with_its_file() {
2584        let (tmp, path) = temp_settings_path();
2585        let secret_file = tmp.path().join("client-secret");
2586        fs::create_dir_all(path.parent().unwrap()).unwrap();
2587        fs::write(
2588            &path,
2589            serde_json::json!({ "gmail": { "accounts": { "work": {
2590                "client_secret": "stale",
2591                "client_secret_file": secret_file.to_str().unwrap(),
2592            }}}})
2593            .to_string(),
2594        )
2595        .unwrap();
2596
2597        Settings::upsert_gmail_account(
2598            &path,
2599            "work",
2600            &[("client_secret", serde_json::json!("fresh"))],
2601        )
2602        .unwrap();
2603
2604        let val = read_json(&path);
2605        assert!(val["gmail"]["accounts"]["work"]
2606            .get("client_secret")
2607            .is_none());
2608        assert_eq!(fs::read_to_string(&secret_file).unwrap(), "fresh\n");
2609    }
2610
2611    #[test]
2612    fn upsert_account_leaves_settings_untouched_when_the_secret_write_fails() {
2613        let (tmp, path) = temp_settings_path();
2614        let token_file = tmp.path().join("missing-dir").join("refresh-token");
2615        settings_with_secret_files(&path, None, &token_file);
2616        let before = fs::read_to_string(&path).unwrap();
2617
2618        let err = Settings::upsert_drive_account(
2619            &path,
2620            "work",
2621            &[
2622                ("scope", serde_json::json!("s")),
2623                ("refresh_token", serde_json::json!("new-token")),
2624            ],
2625        )
2626        .unwrap_err();
2627
2628        assert!(
2629            err.to_string()
2630                .contains("drive.accounts.work.refresh_token_file"),
2631            "{err}"
2632        );
2633        assert!(!err.to_string().contains("new-token"), "{err}");
2634        assert_eq!(fs::read_to_string(&path).unwrap(), before);
2635    }
2636
2637    /// Writes `contents` owner-only to `name` under `dir`.
2638    fn owner_only(dir: &Path, name: &str, contents: &str) -> std::path::PathBuf {
2639        let file = dir.join(name);
2640        fs::write(&file, contents).unwrap();
2641        #[cfg(unix)]
2642        {
2643            use std::os::unix::fs::PermissionsExt;
2644            fs::set_permissions(&file, fs::Permissions::from_mode(0o600)).unwrap();
2645        }
2646        file
2647    }
2648
2649    #[test]
2650    fn upsert_account_checks_every_secret_file_before_writing_any() {
2651        let (tmp, path) = temp_settings_path();
2652        let secret_file = owner_only(tmp.path(), "client-secret", "old-secret\n");
2653        let token_file = tmp.path().join("missing-dir").join("refresh-token");
2654        settings_with_secret_files(&path, Some(&secret_file), &token_file);
2655
2656        Settings::upsert_drive_account(
2657            &path,
2658            "work",
2659            &[
2660                ("client_secret", serde_json::json!("new-secret")),
2661                ("refresh_token", serde_json::json!("new-token")),
2662            ],
2663        )
2664        .unwrap_err();
2665
2666        assert_eq!(fs::read_to_string(&secret_file).unwrap(), "old-secret\n");
2667    }
2668
2669    #[test]
2670    fn upsert_account_refuses_to_replace_a_client_secret_file_of_another_client() {
2671        let (tmp, path) = temp_settings_path();
2672        let secret_file = owner_only(tmp.path(), "client-secret", "old-secret\n");
2673        let token_file = tmp.path().join("refresh-token");
2674        settings_with_secret_files(&path, Some(&secret_file), &token_file);
2675        let before = fs::read_to_string(&path).unwrap();
2676
2677        let err = Settings::upsert_drive_account(
2678            &path,
2679            "work",
2680            &[
2681                ("client_id", serde_json::json!("other-id")),
2682                ("client_secret", serde_json::json!("other-secret")),
2683                ("refresh_token", serde_json::json!("new-token")),
2684            ],
2685        )
2686        .unwrap_err();
2687
2688        let message = err.to_string();
2689        assert!(
2690            message.contains("drive.accounts.work.client_secret_file"),
2691            "{message}"
2692        );
2693        assert!(message.contains("other-id"), "{message}");
2694        assert!(!message.contains("other-secret"), "{message}");
2695        assert_eq!(fs::read_to_string(&secret_file).unwrap(), "old-secret\n");
2696        assert!(!token_file.exists());
2697        assert_eq!(fs::read_to_string(&path).unwrap(), before);
2698    }
2699
2700    #[test]
2701    fn upsert_account_replaces_a_client_secret_file_for_the_same_client() {
2702        let (tmp, path) = temp_settings_path();
2703        let secret_file = owner_only(tmp.path(), "client-secret", "old-secret\n");
2704        let token_file = tmp.path().join("refresh-token");
2705        settings_with_secret_files(&path, Some(&secret_file), &token_file);
2706
2707        Settings::upsert_drive_account(
2708            &path,
2709            "work",
2710            &[
2711                ("client_id", serde_json::json!("id")),
2712                ("client_secret", serde_json::json!("rotated-secret")),
2713                ("refresh_token", serde_json::json!("new-token")),
2714            ],
2715        )
2716        .unwrap();
2717
2718        assert_eq!(
2719            fs::read_to_string(&secret_file).unwrap(),
2720            "rotated-secret\n"
2721        );
2722        assert_eq!(fs::read_to_string(&token_file).unwrap(), "new-token\n");
2723    }
2724
2725    #[test]
2726    fn upsert_gmail_account_writes_a_refresh_token_into_its_file() {
2727        let (tmp, path) = temp_settings_path();
2728        let token_file = tmp.path().join("gmail-refresh-token");
2729        fs::create_dir_all(path.parent().unwrap()).unwrap();
2730        fs::write(
2731            &path,
2732            serde_json::json!({ "gmail": { "accounts": { "work": {
2733                "refresh_token_file": token_file.to_str().unwrap(),
2734            }}}})
2735            .to_string(),
2736        )
2737        .unwrap();
2738
2739        Settings::upsert_gmail_account(
2740            &path,
2741            "work",
2742            &[("refresh_token", serde_json::json!("gmail-token"))],
2743        )
2744        .unwrap();
2745
2746        assert!(!fs::read_to_string(&path).unwrap().contains("gmail-token"));
2747        assert_eq!(fs::read_to_string(&token_file).unwrap(), "gmail-token\n");
2748        let settings = Settings::load_from_path(&path).unwrap();
2749        assert!(settings.gmail.accounts["work"].refresh_token.is_none());
2750    }
2751
2752    /// Drive's twin of
2753    /// `upsert_gmail_account_writes_a_bool_value_that_round_trips_through_settings_load`
2754    /// — see that test's doc comment for the PR #1528 review comment this
2755    /// closes.
2756    #[test]
2757    fn upsert_drive_account_writes_a_bool_value_that_round_trips_through_settings_load() {
2758        let (_tmp, path) = temp_settings_path();
2759
2760        Settings::upsert_drive_account(
2761            &path,
2762            "work",
2763            &[("chrome_profile_from_email", serde_json::Value::Bool(true))],
2764        )
2765        .unwrap();
2766
2767        let val = read_json(&path);
2768        assert_eq!(
2769            val["drive"]["accounts"]["work"]["chrome_profile_from_email"],
2770            true
2771        );
2772
2773        let settings = Settings::load_from_path(&path).unwrap();
2774        assert!(
2775            settings.drive.accounts["work"].chrome_profile_from_email,
2776            "the bool field must deserialize back to `true`, not the string \"true\""
2777        );
2778    }
2779
2780    // ── write_permissions (issue #1574) ─────────────────────────────────
2781
2782    #[test]
2783    fn write_permissions_field_round_trips_through_settings_load() {
2784        let (_tmp, path) = temp_settings_path();
2785        fs::create_dir_all(path.parent().unwrap()).unwrap();
2786        fs::write(
2787            &path,
2788            r#"{
2789                "drive": {
2790                    "accounts": {
2791                        "work": {
2792                            "write_permissions": {
2793                                "rules": [
2794                                    {
2795                                        "folder_id": "folder-1",
2796                                        "recursive": true,
2797                                        "allow": ["create", "upload"],
2798                                        "deny": ["edit"]
2799                                    }
2800                                ]
2801                            }
2802                        }
2803                    }
2804                }
2805            }"#,
2806        )
2807        .unwrap();
2808
2809        let settings = Settings::load_from_path(&path).unwrap();
2810        let rules = &settings.drive.accounts["work"].write_permissions.rules;
2811        assert_eq!(rules.len(), 1);
2812        assert_eq!(rules[0].folder_id.as_deref(), Some("folder-1"));
2813        assert!(rules[0].recursive);
2814        assert!(rules[0]
2815            .allow
2816            .contains(&crate::drive::write_gate::DriveOperation::Create));
2817        assert!(rules[0]
2818            .allow
2819            .contains(&crate::drive::write_gate::DriveOperation::Upload));
2820        assert!(rules[0]
2821            .deny
2822            .contains(&crate::drive::write_gate::DriveOperation::Edit));
2823    }
2824
2825    #[test]
2826    fn write_permissions_absent_defaults_to_empty_rules() {
2827        let (_tmp, path) = temp_settings_path();
2828        fs::create_dir_all(path.parent().unwrap()).unwrap();
2829        fs::write(
2830            &path,
2831            r#"{"drive": {"accounts": {"work": {"client_id": "id"}}}}"#,
2832        )
2833        .unwrap();
2834
2835        let settings = Settings::load_from_path(&path).unwrap();
2836        assert!(settings.drive.accounts["work"]
2837            .write_permissions
2838            .rules
2839            .is_empty());
2840    }
2841
2842    /// Mirrors the crate-wide unknown-field-preserving convention every
2843    /// other settings block already guarantees: a future field inside
2844    /// `write_permissions` that this version of `omni-dev` doesn't know
2845    /// about must survive an unrelated read-modify-write cycle untouched,
2846    /// since writes go through the untyped `serde_json::Value` merge path
2847    /// ([`read_or_default_settings`]/[`ensure_object_at`]), never a
2848    /// round-trip through the typed struct.
2849    #[test]
2850    fn write_permissions_unknown_future_field_is_preserved_through_read_modify_write() {
2851        let (_tmp, path) = temp_settings_path();
2852        fs::create_dir_all(path.parent().unwrap()).unwrap();
2853        fs::write(
2854            &path,
2855            r#"{
2856                "drive": {
2857                    "accounts": {
2858                        "work": {
2859                            "write_permissions": {
2860                                "rules": [],
2861                                "future_field": "not yet modeled"
2862                            }
2863                        }
2864                    }
2865                }
2866            }"#,
2867        )
2868        .unwrap();
2869
2870        Settings::upsert_drive_account(
2871            &path,
2872            "work",
2873            &[("client_id", serde_json::Value::String("id".to_string()))],
2874        )
2875        .unwrap();
2876
2877        let val = read_json(&path);
2878        assert_eq!(
2879            val["drive"]["accounts"]["work"]["write_permissions"]["future_field"],
2880            "not yet modeled"
2881        );
2882        assert_eq!(val["drive"]["accounts"]["work"]["client_id"], "id");
2883    }
2884
2885    #[test]
2886    fn remove_drive_account_true_when_present_false_when_absent() {
2887        let (_tmp, path) = temp_settings_path();
2888        fs::create_dir_all(path.parent().unwrap()).unwrap();
2889        fs::write(
2890            &path,
2891            r#"{"drive": {"accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
2892        )
2893        .unwrap();
2894
2895        assert!(Settings::remove_drive_account(&path, "work").unwrap());
2896        let val = read_json(&path);
2897        assert!(val["drive"]["accounts"].get("work").is_none());
2898        assert_eq!(val["drive"]["accounts"]["personal"]["client_id"], "keep");
2899
2900        assert!(!Settings::remove_drive_account(&path, "work").unwrap());
2901    }
2902
2903    #[test]
2904    fn remove_drive_account_clears_default_account_when_it_named_the_removed_account() {
2905        let (_tmp, path) = temp_settings_path();
2906        fs::create_dir_all(path.parent().unwrap()).unwrap();
2907        fs::write(
2908            &path,
2909            r#"{"drive": {"default_account": "work", "accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
2910        )
2911        .unwrap();
2912
2913        assert!(Settings::remove_drive_account(&path, "work").unwrap());
2914        let val = read_json(&path);
2915        assert!(val["drive"].get("default_account").is_none());
2916        assert_eq!(val["drive"]["accounts"]["personal"]["client_id"], "keep");
2917    }
2918
2919    #[test]
2920    fn remove_drive_account_leaves_default_account_untouched_when_it_names_a_different_account() {
2921        let (_tmp, path) = temp_settings_path();
2922        fs::create_dir_all(path.parent().unwrap()).unwrap();
2923        fs::write(
2924            &path,
2925            r#"{"drive": {"default_account": "personal", "accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
2926        )
2927        .unwrap();
2928
2929        assert!(Settings::remove_drive_account(&path, "work").unwrap());
2930        let val = read_json(&path);
2931        assert_eq!(val["drive"]["default_account"], "personal");
2932    }
2933
2934    #[test]
2935    fn remove_drive_account_false_when_file_missing() {
2936        let (_tmp, path) = temp_settings_path();
2937        assert!(!Settings::remove_drive_account(&path, "work").unwrap());
2938        assert!(!path.exists());
2939    }
2940
2941    #[test]
2942    fn set_drive_default_account_sets_and_clears() {
2943        let (_tmp, path) = temp_settings_path();
2944
2945        Settings::set_drive_default_account(&path, Some("work")).unwrap();
2946        assert_eq!(read_json(&path)["drive"]["default_account"], "work");
2947
2948        Settings::set_drive_default_account(&path, None).unwrap();
2949        assert!(read_json(&path)["drive"].get("default_account").is_none());
2950    }
2951
2952    // ── file-id rules (issue #1612) ─────────────────────────────────────
2953
2954    /// Writes `rules` as an account's `write_permissions.rules` and loads it.
2955    fn load_with_rules(rules_json: &str) -> Result<Settings> {
2956        let (_tmp, path) = temp_settings_path();
2957        fs::create_dir_all(path.parent().unwrap()).unwrap();
2958        fs::write(
2959            &path,
2960            format!(
2961                r#"{{"drive":{{"accounts":{{"work":{{"write_permissions":{{"rules":{rules_json}}}}}}}}}}}"#
2962            ),
2963        )
2964        .unwrap();
2965        Settings::load_from_path(&path)
2966    }
2967
2968    #[test]
2969    fn a_file_id_rule_round_trips_through_settings_load() {
2970        let settings =
2971            load_with_rules(r#"[{"file_id":"sheet-1","allow":["sheets-write"]}]"#).unwrap();
2972        let rules = &settings.drive.accounts["work"].write_permissions.rules;
2973        assert_eq!(rules.len(), 1);
2974        assert_eq!(rules[0].file_id.as_deref(), Some("sheet-1"));
2975        assert_eq!(rules[0].folder_id, None);
2976        assert!(!rules[0].recursive);
2977        assert!(rules[0]
2978            .allow
2979            .contains(&crate::drive::write_gate::DriveOperation::SheetsWrite));
2980    }
2981
2982    #[test]
2983    fn folder_and_file_rules_coexist_in_one_list() {
2984        let settings = load_with_rules(
2985            r#"[{"folder_id":"f1","recursive":true,"allow":["create"]},
2986                {"file_id":"x1","deny":["edit"]}]"#,
2987        )
2988        .unwrap();
2989        let rules = &settings.drive.accounts["work"].write_permissions.rules;
2990        assert_eq!(rules.len(), 2);
2991        assert_eq!(rules[0].folder_id.as_deref(), Some("f1"));
2992        assert_eq!(rules[1].file_id.as_deref(), Some("x1"));
2993    }
2994
2995    #[test]
2996    fn a_rule_naming_no_target_fails_the_whole_load() {
2997        // Fail-closed: `active_account_rules` turns a load error into an
2998        // empty rule set, which denies every write.
2999        assert!(load_with_rules(r#"[{"allow":["create"]}]"#).is_err());
3000    }
3001
3002    #[test]
3003    fn a_rule_naming_both_targets_fails_the_whole_load() {
3004        assert!(load_with_rules(r#"[{"folder_id":"f1","file_id":"x1"}]"#).is_err());
3005    }
3006
3007    #[test]
3008    fn recursive_true_on_a_file_rule_fails_the_whole_load() {
3009        assert!(load_with_rules(r#"[{"file_id":"x1","recursive":true}]"#).is_err());
3010    }
3011
3012    #[test]
3013    fn an_older_binary_would_fail_closed_on_a_file_rule() {
3014        // The backward-compatibility claim, pinned. An older binary has
3015        // `folder_id: String` (required), so a file rule is a missing-field
3016        // error there — exactly what this asserts by omitting `folder_id`
3017        // from a shape that requires one. `Settings::load()` then fails and
3018        // `active_account_rules` degrades to zero rules: every write denied,
3019        // never silently widened. Same mechanism ADR-0073 §3 accepted for a
3020        // new `DriveOperation` variant.
3021        #[derive(Debug, serde::Deserialize)]
3022        #[allow(dead_code)]
3023        struct OldFolderPermissionRule {
3024            folder_id: String,
3025        }
3026        let err = serde_json::from_str::<OldFolderPermissionRule>(r#"{"file_id":"x1"}"#)
3027            .unwrap_err()
3028            .to_string();
3029        assert!(err.contains("folder_id"), "{err}");
3030    }
3031
3032    #[test]
3033    fn upsert_drive_account_preserves_file_id_rules_it_cannot_understand() {
3034        // Settings *writes* go through an untyped `serde_json::Value`
3035        // merge, so an older binary running `drive auth login` cannot drop
3036        // the file rules it failed to parse.
3037        let (_tmp, path) = temp_settings_path();
3038        fs::create_dir_all(path.parent().unwrap()).unwrap();
3039        fs::write(
3040            &path,
3041            r#"{"drive":{"accounts":{"work":{"write_permissions":{"rules":[
3042                {"file_id":"sheet-1","allow":["sheets-write"]}]}}}}}"#,
3043        )
3044        .unwrap();
3045
3046        Settings::upsert_drive_account(
3047            &path,
3048            "work",
3049            &[("email", serde_json::Value::String("a@b.c".to_string()))],
3050        )
3051        .unwrap();
3052
3053        let reloaded = Settings::load_from_path(&path).unwrap();
3054        let rules = &reloaded.drive.accounts["work"].write_permissions.rules;
3055        assert_eq!(rules.len(), 1);
3056        assert_eq!(rules[0].file_id.as_deref(), Some("sheet-1"));
3057    }
3058}