Skip to main content

omni_dev/drive/
account.rs

1//! Named Drive account resolution (mirrors issue #1500/
2//! [ADR-0066](../../docs/adrs/adr-0066.md), applied to Drive by
3//! [ADR-0069](../../docs/adrs/adr-0069.md)).
4//!
5//! A second, Drive-specific multi-tenancy axis, orthogonal to `--profile`
6//! ([ADR-0045](../../docs/adrs/adr-0045.md)): named accounts in the `drive`
7//! block of `settings.json`, selected per invocation via `--account` /
8//! [`DRIVE_ACCOUNT_ENV`]. [`resolve_account`] (reads) and
9//! [`resolve_account_for_write`] (account-creating writes: `login`,
10//! `import`) are the two entry points the credential CRUD layer
11//! (`crate::drive::auth`) and the CLI/MCP surfaces route through.
12//!
13//! Unlike `crate::gmail::account`, there is no pre-existing single-account
14//! Drive credential store to preserve — Drive is a brand-new feature — so
15//! this module's [`ResolvedAccount`] has an `Unconfigured` variant rather
16//! than Gmail's `Legacy`, and there is no
17//! `is_first_legacy_to_named_transition`-style shadowing-notice machinery
18//! (see [ADR-0069](../../docs/adrs/adr-0069.md) §3).
19
20use anyhow::Result;
21use serde::Serialize;
22
23use crate::drive::auth::{DRIVE_CLIENT_ID, DRIVE_CLIENT_SECRET, DRIVE_REFRESH_TOKEN};
24use crate::utils::env::EnvSource;
25use crate::utils::settings::DriveSettings;
26
27/// Selects the active Drive account for every command, mirroring
28/// `OMNI_DEV_PROFILE`. Propagated by the global `--account` flag in
29/// `DriveCommand`.
30pub const DRIVE_ACCOUNT_ENV: &str = "OMNI_DEV_DRIVE_ACCOUNT";
31
32/// Returns the account named by [`DRIVE_ACCOUNT_ENV`] in `raw`, or `None`
33/// when unset or empty.
34///
35/// Reads the **raw** env only, mirroring
36/// [`active_profile_from`](crate::utils::settings::active_profile_from) —
37/// pure over the injected source, never resolves through account fallback.
38pub fn active_drive_account_from<E: EnvSource>(raw: &E) -> Option<String> {
39    raw.var(DRIVE_ACCOUNT_ENV).filter(|s| !s.is_empty())
40}
41
42/// Which credential source a Drive command should read from, as decided by
43/// [`resolve_account`].
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum ResolvedAccount {
46    /// A named account: `drive.accounts.<name>`.
47    Named(String),
48    /// No named account applies: either the literal-env bypass is present
49    /// (a real credential source with nothing further to resolve), or
50    /// `drive.accounts` is empty (nothing configured at all — the
51    /// credential-load step must fail loudly with a "run `drive auth
52    /// login`" remedy rather than attempt a network call).
53    Unconfigured,
54}
55
56/// Resolves which Drive account a *read* command (credential load, status,
57/// client construction, MCP tools, …) should use.
58///
59/// `explicit` is the already-resolved `--account`/[`DRIVE_ACCOUNT_ENV`]
60/// value, if any (production callers pass the result of calling
61/// [`active_drive_account_from`] on `&SystemEnv`, or an MCP per-call
62/// override).
63///
64/// Precedence:
65/// 1. A literal `DRIVE_CLIENT_ID`+`DRIVE_CLIENT_SECRET`+`DRIVE_REFRESH_TOKEN`
66///    all present in `raw` bypasses account resolution entirely — a
67///    scripting/CI convenience, not a migration path (Drive has nothing to
68///    migrate from).
69/// 2. `explicit`, if set, selects `drive.accounts.<name>`; an unknown name
70///    is a hard error listing the configured accounts.
71/// 3. No explicit account, `settings.accounts` non-empty: `default_account`
72///    if it still names a configured account, else the sole account if
73///    exactly one is configured, else a hard error.
74/// 4. `settings.accounts` empty: [`ResolvedAccount::Unconfigured`].
75///
76/// For `drive auth login`/`drive auth import`, which *create* an account
77/// rather than select an existing one, see [`resolve_account_for_write`] —
78/// this function's rule 2 would otherwise make it impossible to ever
79/// configure a brand-new named account, since it would reject the very name
80/// being created as "unknown".
81pub fn resolve_account<E: EnvSource>(
82    raw: &E,
83    settings: &DriveSettings,
84    explicit: Option<&str>,
85) -> Result<ResolvedAccount> {
86    if has_literal_credentials(raw) {
87        return Ok(ResolvedAccount::Unconfigured);
88    }
89    if let Some(name) = explicit {
90        validate_account(settings, name)?;
91        return Ok(ResolvedAccount::Named(name.to_string()));
92    }
93    if settings.accounts.is_empty() {
94        return Ok(ResolvedAccount::Unconfigured);
95    }
96    resolve_default_account(settings).map(ResolvedAccount::Named)
97}
98
99/// Resolves which Drive account a *write* command that can create a new
100/// account (`drive auth login`, `drive auth import`) should target.
101///
102/// Identical to [`resolve_account`] except for rule 2: an explicit
103/// `--account`/[`DRIVE_ACCOUNT_ENV`] name need not already be configured —
104/// these commands are how an account comes into existence in the first
105/// place, so it is always accepted as the target rather than validated.
106/// Rules 1, 3, and 4 (literal-env bypass, default/sole-account fallback,
107/// empty-accounts fallback) are unchanged, so a plain `drive auth login`
108/// with no `--account` still resolves exactly like [`resolve_account`]
109/// would.
110pub fn resolve_account_for_write<E: EnvSource>(
111    raw: &E,
112    settings: &DriveSettings,
113    explicit: Option<&str>,
114) -> Result<ResolvedAccount> {
115    if has_literal_credentials(raw) {
116        return Ok(ResolvedAccount::Unconfigured);
117    }
118    if let Some(name) = explicit {
119        return Ok(ResolvedAccount::Named(name.to_string()));
120    }
121    if settings.accounts.is_empty() {
122        return Ok(ResolvedAccount::Unconfigured);
123    }
124    resolve_default_account(settings).map(ResolvedAccount::Named)
125}
126
127/// Whether `raw` carries a complete literal credential set directly in the
128/// process environment (rule 1 of [`resolve_account`]).
129fn has_literal_credentials<E: EnvSource>(raw: &E) -> bool {
130    raw.var(DRIVE_CLIENT_ID).is_some()
131        && raw.var(DRIVE_CLIENT_SECRET).is_some()
132        && raw.var(DRIVE_REFRESH_TOKEN).is_some()
133}
134
135/// Validates that `name` is a known Drive account.
136///
137/// Mirrors
138/// [`Settings::validate_profile`](crate::utils::settings::Settings::validate_profile)'s
139/// exact message convention (sorted key list, `"(none)"` sentinel). Exposed
140/// beyond this module so `drive account set-default` can reuse the same
141/// error text rather than duplicating it.
142pub fn validate_account(settings: &DriveSettings, name: &str) -> Result<()> {
143    if settings.accounts.contains_key(name) {
144        return Ok(());
145    }
146    Err(anyhow::anyhow!(
147        "unknown Drive account '{name}'; known accounts: {}",
148        known_accounts(settings)
149    ))
150}
151
152/// The configured account names, sorted and comma-joined, or `"(none)"` when
153/// none are configured — the shared error-message fragment.
154fn known_accounts(settings: &DriveSettings) -> String {
155    if settings.accounts.is_empty() {
156        "(none)".to_string()
157    } else {
158        let mut names: Vec<&str> = settings.accounts.keys().map(String::as_str).collect();
159        names.sort_unstable();
160        names.join(", ")
161    }
162}
163
164/// Resolves the account to use when no `--account`/[`DRIVE_ACCOUNT_ENV`] is
165/// given and at least one account is configured (rule 3 of
166/// [`resolve_account`]).
167fn resolve_default_account(settings: &DriveSettings) -> Result<String> {
168    if let Some(default) = &settings.default_account {
169        return if settings.accounts.contains_key(default) {
170            Ok(default.clone())
171        } else {
172            Err(anyhow::anyhow!(
173                "configured default Drive account '{default}' no longer exists; known accounts: {}",
174                known_accounts(settings)
175            ))
176        };
177    }
178    let mut names = settings.accounts.keys();
179    if let (Some(sole), None) = (names.next(), names.next()) {
180        return Ok(sole.clone());
181    }
182    Err(anyhow::anyhow!(
183        "multiple Drive accounts configured and no default set; pass --account or run \
184         `drive account set-default <name>`"
185    ))
186}
187
188/// One row of `drive account list` / the `drive_account_list` MCP tool.
189/// Never carries a secret — only what's safe to render.
190#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
191pub struct AccountSummary {
192    /// The account name passed to `--account`.
193    pub name: String,
194    /// The display-only cached email address, if known.
195    pub email_address: Option<String>,
196    /// The OAuth2 scope this account was authorized with, if known.
197    pub scope: Option<String>,
198    /// Whether this is `drive.default_account`.
199    pub is_default: bool,
200}
201
202/// Lists configured Drive accounts, sorted by name.
203pub fn list_accounts(settings: &DriveSettings) -> Vec<AccountSummary> {
204    let mut names: Vec<&String> = settings.accounts.keys().collect();
205    names.sort();
206    names
207        .into_iter()
208        .map(|name| {
209            let account = &settings.accounts[name];
210            AccountSummary {
211                name: name.clone(),
212                email_address: account.email_address.clone(),
213                scope: account.scope.clone(),
214                is_default: settings.default_account.as_deref() == Some(name.as_str()),
215            }
216        })
217        .collect()
218}
219
220#[cfg(test)]
221#[allow(clippy::unwrap_used)]
222mod tests {
223    use super::*;
224    use crate::test_support::env::MapEnv;
225    use crate::utils::settings::DriveAccountSettings;
226    use std::collections::HashMap;
227
228    fn account(email: Option<&str>, scope: Option<&str>) -> DriveAccountSettings {
229        DriveAccountSettings {
230            client_id: Some("id".to_string()),
231            client_secret: Some("secret".to_string()),
232            refresh_token: Some("token".to_string()),
233            scope: scope.map(str::to_string),
234            email_address: email.map(str::to_string),
235            chrome_profile_from_email: false,
236            browser_command: None,
237        }
238    }
239
240    fn settings_with(
241        default_account: Option<&str>,
242        accounts: &[(&str, DriveAccountSettings)],
243    ) -> DriveSettings {
244        DriveSettings {
245            default_account: default_account.map(str::to_string),
246            accounts: accounts
247                .iter()
248                .map(|(name, acc)| {
249                    (
250                        (*name).to_string(),
251                        DriveAccountSettings {
252                            client_id: acc.client_id.clone(),
253                            client_secret: acc.client_secret.clone(),
254                            refresh_token: acc.refresh_token.clone(),
255                            scope: acc.scope.clone(),
256                            email_address: acc.email_address.clone(),
257                            chrome_profile_from_email: acc.chrome_profile_from_email,
258                            browser_command: acc.browser_command.clone(),
259                        },
260                    )
261                })
262                .collect::<HashMap<_, _>>(),
263        }
264    }
265
266    #[test]
267    fn active_drive_account_from_reads_and_trims_empty() {
268        assert_eq!(
269            active_drive_account_from(&MapEnv::new().with(DRIVE_ACCOUNT_ENV, "work")),
270            Some("work".to_string())
271        );
272        assert_eq!(
273            active_drive_account_from(&MapEnv::new().with(DRIVE_ACCOUNT_ENV, "")),
274            None
275        );
276        assert_eq!(active_drive_account_from(&MapEnv::new()), None);
277    }
278
279    #[test]
280    fn resolve_literal_env_bypasses_account_resolution() {
281        let raw = MapEnv::new()
282            .with(DRIVE_CLIENT_ID, "id")
283            .with(DRIVE_CLIENT_SECRET, "secret")
284            .with(DRIVE_REFRESH_TOKEN, "token");
285        // Even with an ambiguous multi-account store and no default, or an
286        // explicit account, literal env wins outright.
287        let settings = settings_with(
288            None,
289            &[("a", account(None, None)), ("b", account(None, None))],
290        );
291        assert_eq!(
292            resolve_account(&raw, &settings, Some("a")).unwrap(),
293            ResolvedAccount::Unconfigured
294        );
295        assert_eq!(
296            resolve_account(&raw, &settings, None).unwrap(),
297            ResolvedAccount::Unconfigured
298        );
299    }
300
301    #[test]
302    fn resolve_literal_env_requires_all_three_keys() {
303        let raw = MapEnv::new()
304            .with(DRIVE_CLIENT_ID, "id")
305            .with(DRIVE_CLIENT_SECRET, "secret");
306        let settings = settings_with(None, &[("work", account(None, None))]);
307        // Missing DRIVE_REFRESH_TOKEN: falls through to normal resolution
308        // (rule 3, sole account) rather than the literal-env bypass.
309        assert_eq!(
310            resolve_account(&raw, &settings, None).unwrap(),
311            ResolvedAccount::Named("work".to_string())
312        );
313    }
314
315    #[test]
316    fn resolve_explicit_account_selects_named() {
317        let settings = settings_with(None, &[("work", account(None, None))]);
318        assert_eq!(
319            resolve_account(&MapEnv::new(), &settings, Some("work")).unwrap(),
320            ResolvedAccount::Named("work".to_string())
321        );
322    }
323
324    #[test]
325    fn resolve_explicit_unknown_account_errors_with_sorted_list() {
326        let settings = settings_with(
327            None,
328            &[
329                ("work", account(None, None)),
330                ("personal", account(None, None)),
331            ],
332        );
333        let err = resolve_account(&MapEnv::new(), &settings, Some("bogus")).unwrap_err();
334        assert_eq!(
335            err.to_string(),
336            "unknown Drive account 'bogus'; known accounts: personal, work"
337        );
338    }
339
340    #[test]
341    fn resolve_explicit_unknown_account_reports_none_when_empty() {
342        let settings = settings_with(None, &[]);
343        let err = resolve_account(&MapEnv::new(), &settings, Some("bogus")).unwrap_err();
344        assert_eq!(
345            err.to_string(),
346            "unknown Drive account 'bogus'; known accounts: (none)"
347        );
348    }
349
350    // ── resolve_account_for_write (login/import bootstrap a new name) ──
351
352    #[test]
353    fn resolve_for_write_explicit_new_name_is_accepted_without_validation() {
354        // The whole point: an --account name that doesn't exist yet must
355        // succeed for login/import, unlike the read-side resolve_account.
356        let settings = settings_with(None, &[]);
357        assert_eq!(
358            resolve_account_for_write(&MapEnv::new(), &settings, Some("brand-new")).unwrap(),
359            ResolvedAccount::Named("brand-new".to_string())
360        );
361    }
362
363    #[test]
364    fn resolve_for_write_explicit_existing_name_still_selects_it() {
365        let settings = settings_with(None, &[("work", account(None, None))]);
366        assert_eq!(
367            resolve_account_for_write(&MapEnv::new(), &settings, Some("work")).unwrap(),
368            ResolvedAccount::Named("work".to_string())
369        );
370    }
371
372    #[test]
373    fn resolve_for_write_literal_env_still_bypasses_account_resolution() {
374        let raw = MapEnv::new()
375            .with(DRIVE_CLIENT_ID, "id")
376            .with(DRIVE_CLIENT_SECRET, "secret")
377            .with(DRIVE_REFRESH_TOKEN, "token");
378        let settings = settings_with(None, &[]);
379        assert_eq!(
380            resolve_account_for_write(&raw, &settings, Some("brand-new")).unwrap(),
381            ResolvedAccount::Unconfigured
382        );
383    }
384
385    #[test]
386    fn resolve_for_write_no_explicit_matches_read_side_fallback_rules() {
387        // No --account given: falls back to the same default/sole-account/
388        // empty-is-unconfigured rules as resolve_account.
389        let sole = settings_with(None, &[("work", account(None, None))]);
390        assert_eq!(
391            resolve_account_for_write(&MapEnv::new(), &sole, None).unwrap(),
392            ResolvedAccount::Named("work".to_string())
393        );
394
395        let empty = settings_with(None, &[]);
396        assert_eq!(
397            resolve_account_for_write(&MapEnv::new(), &empty, None).unwrap(),
398            ResolvedAccount::Unconfigured
399        );
400
401        let ambiguous = settings_with(
402            None,
403            &[
404                ("work", account(None, None)),
405                ("personal", account(None, None)),
406            ],
407        );
408        assert!(resolve_account_for_write(&MapEnv::new(), &ambiguous, None).is_err());
409    }
410
411    #[test]
412    fn resolve_empty_accounts_is_unconfigured() {
413        let settings = settings_with(None, &[]);
414        assert_eq!(
415            resolve_account(&MapEnv::new(), &settings, None).unwrap(),
416            ResolvedAccount::Unconfigured
417        );
418    }
419
420    #[test]
421    fn resolve_no_explicit_uses_valid_default() {
422        let settings = settings_with(
423            Some("work"),
424            &[
425                ("work", account(None, None)),
426                ("personal", account(None, None)),
427            ],
428        );
429        assert_eq!(
430            resolve_account(&MapEnv::new(), &settings, None).unwrap(),
431            ResolvedAccount::Named("work".to_string())
432        );
433    }
434
435    #[test]
436    fn resolve_no_explicit_stale_default_errors() {
437        let settings = settings_with(Some("gone"), &[("work", account(None, None))]);
438        let err = resolve_account(&MapEnv::new(), &settings, None).unwrap_err();
439        assert_eq!(
440            err.to_string(),
441            "configured default Drive account 'gone' no longer exists; known accounts: work"
442        );
443    }
444
445    #[test]
446    fn resolve_no_explicit_no_default_falls_back_to_sole_account() {
447        let settings = settings_with(None, &[("work", account(None, None))]);
448        assert_eq!(
449            resolve_account(&MapEnv::new(), &settings, None).unwrap(),
450            ResolvedAccount::Named("work".to_string())
451        );
452    }
453
454    #[test]
455    fn resolve_no_explicit_no_default_multiple_accounts_errors() {
456        let settings = settings_with(
457            None,
458            &[
459                ("work", account(None, None)),
460                ("personal", account(None, None)),
461            ],
462        );
463        let err = resolve_account(&MapEnv::new(), &settings, None).unwrap_err();
464        assert_eq!(
465            err.to_string(),
466            "multiple Drive accounts configured and no default set; pass --account or run \
467             `drive account set-default <name>`"
468        );
469    }
470
471    #[test]
472    fn list_accounts_sorted_and_marks_default() {
473        let settings = settings_with(
474            Some("work"),
475            &[
476                (
477                    "personal",
478                    account(Some("me@gmail.com"), Some("drive.readonly")),
479                ),
480                ("work", account(Some("me@work.com"), Some("drive.readonly"))),
481            ],
482        );
483        let rows = list_accounts(&settings);
484        assert_eq!(rows.len(), 2);
485        assert_eq!(rows[0].name, "personal");
486        assert_eq!(rows[0].email_address.as_deref(), Some("me@gmail.com"));
487        assert_eq!(rows[0].scope.as_deref(), Some("drive.readonly"));
488        assert!(!rows[0].is_default);
489        assert_eq!(rows[1].name, "work");
490        assert!(rows[1].is_default);
491    }
492
493    #[test]
494    fn list_accounts_empty_when_no_accounts() {
495        assert!(list_accounts(&settings_with(None, &[])).is_empty());
496    }
497}