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            write_permissions: crate::utils::settings::WritePermissionsSettings::default(),
238        }
239    }
240
241    fn settings_with(
242        default_account: Option<&str>,
243        accounts: &[(&str, DriveAccountSettings)],
244    ) -> DriveSettings {
245        DriveSettings {
246            default_account: default_account.map(str::to_string),
247            accounts: accounts
248                .iter()
249                .map(|(name, acc)| {
250                    (
251                        (*name).to_string(),
252                        DriveAccountSettings {
253                            client_id: acc.client_id.clone(),
254                            client_secret: acc.client_secret.clone(),
255                            refresh_token: acc.refresh_token.clone(),
256                            scope: acc.scope.clone(),
257                            email_address: acc.email_address.clone(),
258                            chrome_profile_from_email: acc.chrome_profile_from_email,
259                            browser_command: acc.browser_command.clone(),
260                            write_permissions:
261                                crate::utils::settings::WritePermissionsSettings::default(),
262                        },
263                    )
264                })
265                .collect::<HashMap<_, _>>(),
266        }
267    }
268
269    #[test]
270    fn active_drive_account_from_reads_and_trims_empty() {
271        assert_eq!(
272            active_drive_account_from(&MapEnv::new().with(DRIVE_ACCOUNT_ENV, "work")),
273            Some("work".to_string())
274        );
275        assert_eq!(
276            active_drive_account_from(&MapEnv::new().with(DRIVE_ACCOUNT_ENV, "")),
277            None
278        );
279        assert_eq!(active_drive_account_from(&MapEnv::new()), None);
280    }
281
282    #[test]
283    fn resolve_literal_env_bypasses_account_resolution() {
284        let raw = MapEnv::new()
285            .with(DRIVE_CLIENT_ID, "id")
286            .with(DRIVE_CLIENT_SECRET, "secret")
287            .with(DRIVE_REFRESH_TOKEN, "token");
288        // Even with an ambiguous multi-account store and no default, or an
289        // explicit account, literal env wins outright.
290        let settings = settings_with(
291            None,
292            &[("a", account(None, None)), ("b", account(None, None))],
293        );
294        assert_eq!(
295            resolve_account(&raw, &settings, Some("a")).unwrap(),
296            ResolvedAccount::Unconfigured
297        );
298        assert_eq!(
299            resolve_account(&raw, &settings, None).unwrap(),
300            ResolvedAccount::Unconfigured
301        );
302    }
303
304    #[test]
305    fn resolve_literal_env_requires_all_three_keys() {
306        let raw = MapEnv::new()
307            .with(DRIVE_CLIENT_ID, "id")
308            .with(DRIVE_CLIENT_SECRET, "secret");
309        let settings = settings_with(None, &[("work", account(None, None))]);
310        // Missing DRIVE_REFRESH_TOKEN: falls through to normal resolution
311        // (rule 3, sole account) rather than the literal-env bypass.
312        assert_eq!(
313            resolve_account(&raw, &settings, None).unwrap(),
314            ResolvedAccount::Named("work".to_string())
315        );
316    }
317
318    #[test]
319    fn resolve_explicit_account_selects_named() {
320        let settings = settings_with(None, &[("work", account(None, None))]);
321        assert_eq!(
322            resolve_account(&MapEnv::new(), &settings, Some("work")).unwrap(),
323            ResolvedAccount::Named("work".to_string())
324        );
325    }
326
327    #[test]
328    fn resolve_explicit_unknown_account_errors_with_sorted_list() {
329        let settings = settings_with(
330            None,
331            &[
332                ("work", account(None, None)),
333                ("personal", account(None, None)),
334            ],
335        );
336        let err = resolve_account(&MapEnv::new(), &settings, Some("bogus")).unwrap_err();
337        assert_eq!(
338            err.to_string(),
339            "unknown Drive account 'bogus'; known accounts: personal, work"
340        );
341    }
342
343    #[test]
344    fn resolve_explicit_unknown_account_reports_none_when_empty() {
345        let settings = settings_with(None, &[]);
346        let err = resolve_account(&MapEnv::new(), &settings, Some("bogus")).unwrap_err();
347        assert_eq!(
348            err.to_string(),
349            "unknown Drive account 'bogus'; known accounts: (none)"
350        );
351    }
352
353    // ── resolve_account_for_write (login/import bootstrap a new name) ──
354
355    #[test]
356    fn resolve_for_write_explicit_new_name_is_accepted_without_validation() {
357        // The whole point: an --account name that doesn't exist yet must
358        // succeed for login/import, unlike the read-side resolve_account.
359        let settings = settings_with(None, &[]);
360        assert_eq!(
361            resolve_account_for_write(&MapEnv::new(), &settings, Some("brand-new")).unwrap(),
362            ResolvedAccount::Named("brand-new".to_string())
363        );
364    }
365
366    #[test]
367    fn resolve_for_write_explicit_existing_name_still_selects_it() {
368        let settings = settings_with(None, &[("work", account(None, None))]);
369        assert_eq!(
370            resolve_account_for_write(&MapEnv::new(), &settings, Some("work")).unwrap(),
371            ResolvedAccount::Named("work".to_string())
372        );
373    }
374
375    #[test]
376    fn resolve_for_write_literal_env_still_bypasses_account_resolution() {
377        let raw = MapEnv::new()
378            .with(DRIVE_CLIENT_ID, "id")
379            .with(DRIVE_CLIENT_SECRET, "secret")
380            .with(DRIVE_REFRESH_TOKEN, "token");
381        let settings = settings_with(None, &[]);
382        assert_eq!(
383            resolve_account_for_write(&raw, &settings, Some("brand-new")).unwrap(),
384            ResolvedAccount::Unconfigured
385        );
386    }
387
388    #[test]
389    fn resolve_for_write_no_explicit_matches_read_side_fallback_rules() {
390        // No --account given: falls back to the same default/sole-account/
391        // empty-is-unconfigured rules as resolve_account.
392        let sole = settings_with(None, &[("work", account(None, None))]);
393        assert_eq!(
394            resolve_account_for_write(&MapEnv::new(), &sole, None).unwrap(),
395            ResolvedAccount::Named("work".to_string())
396        );
397
398        let empty = settings_with(None, &[]);
399        assert_eq!(
400            resolve_account_for_write(&MapEnv::new(), &empty, None).unwrap(),
401            ResolvedAccount::Unconfigured
402        );
403
404        let ambiguous = settings_with(
405            None,
406            &[
407                ("work", account(None, None)),
408                ("personal", account(None, None)),
409            ],
410        );
411        assert!(resolve_account_for_write(&MapEnv::new(), &ambiguous, None).is_err());
412    }
413
414    #[test]
415    fn resolve_empty_accounts_is_unconfigured() {
416        let settings = settings_with(None, &[]);
417        assert_eq!(
418            resolve_account(&MapEnv::new(), &settings, None).unwrap(),
419            ResolvedAccount::Unconfigured
420        );
421    }
422
423    #[test]
424    fn resolve_no_explicit_uses_valid_default() {
425        let settings = settings_with(
426            Some("work"),
427            &[
428                ("work", account(None, None)),
429                ("personal", account(None, None)),
430            ],
431        );
432        assert_eq!(
433            resolve_account(&MapEnv::new(), &settings, None).unwrap(),
434            ResolvedAccount::Named("work".to_string())
435        );
436    }
437
438    #[test]
439    fn resolve_no_explicit_stale_default_errors() {
440        let settings = settings_with(Some("gone"), &[("work", account(None, None))]);
441        let err = resolve_account(&MapEnv::new(), &settings, None).unwrap_err();
442        assert_eq!(
443            err.to_string(),
444            "configured default Drive account 'gone' no longer exists; known accounts: work"
445        );
446    }
447
448    #[test]
449    fn resolve_no_explicit_no_default_falls_back_to_sole_account() {
450        let settings = settings_with(None, &[("work", account(None, None))]);
451        assert_eq!(
452            resolve_account(&MapEnv::new(), &settings, None).unwrap(),
453            ResolvedAccount::Named("work".to_string())
454        );
455    }
456
457    #[test]
458    fn resolve_no_explicit_no_default_multiple_accounts_errors() {
459        let settings = settings_with(
460            None,
461            &[
462                ("work", account(None, None)),
463                ("personal", account(None, None)),
464            ],
465        );
466        let err = resolve_account(&MapEnv::new(), &settings, None).unwrap_err();
467        assert_eq!(
468            err.to_string(),
469            "multiple Drive accounts configured and no default set; pass --account or run \
470             `drive account set-default <name>`"
471        );
472    }
473
474    #[test]
475    fn list_accounts_sorted_and_marks_default() {
476        let settings = settings_with(
477            Some("work"),
478            &[
479                (
480                    "personal",
481                    account(Some("me@gmail.com"), Some("drive.readonly")),
482                ),
483                ("work", account(Some("me@work.com"), Some("drive.readonly"))),
484            ],
485        );
486        let rows = list_accounts(&settings);
487        assert_eq!(rows.len(), 2);
488        assert_eq!(rows[0].name, "personal");
489        assert_eq!(rows[0].email_address.as_deref(), Some("me@gmail.com"));
490        assert_eq!(rows[0].scope.as_deref(), Some("drive.readonly"));
491        assert!(!rows[0].is_default);
492        assert_eq!(rows[1].name, "work");
493        assert!(rows[1].is_default);
494    }
495
496    #[test]
497    fn list_accounts_empty_when_no_accounts() {
498        assert!(list_accounts(&settings_with(None, &[])).is_empty());
499    }
500}