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