use anyhow::Result;
use serde::Serialize;
use crate::gmail::auth::{GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, GMAIL_REFRESH_TOKEN};
use crate::utils::env::EnvSource;
use crate::utils::settings::GmailSettings;
pub const GMAIL_ACCOUNT_ENV: &str = "OMNI_DEV_GMAIL_ACCOUNT";
pub fn active_gmail_account_from<E: EnvSource>(raw: &E) -> Option<String> {
raw.var(GMAIL_ACCOUNT_ENV).filter(|s| !s.is_empty())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResolvedAccount {
Named(String),
Legacy,
}
pub fn resolve_account<E: EnvSource>(
raw: &E,
settings: &GmailSettings,
explicit: Option<&str>,
) -> Result<ResolvedAccount> {
if has_literal_credentials(raw) {
return Ok(ResolvedAccount::Legacy);
}
if let Some(name) = explicit {
validate_account(settings, name)?;
return Ok(ResolvedAccount::Named(name.to_string()));
}
if settings.accounts.is_empty() {
return Ok(ResolvedAccount::Legacy);
}
resolve_default_account(settings).map(ResolvedAccount::Named)
}
pub fn resolve_account_for_write<E: EnvSource>(
raw: &E,
settings: &GmailSettings,
explicit: Option<&str>,
) -> Result<ResolvedAccount> {
if has_literal_credentials(raw) {
return Ok(ResolvedAccount::Legacy);
}
if let Some(name) = explicit {
return Ok(ResolvedAccount::Named(name.to_string()));
}
if settings.accounts.is_empty() {
return Ok(ResolvedAccount::Legacy);
}
resolve_default_account(settings).map(ResolvedAccount::Named)
}
fn has_literal_credentials<E: EnvSource>(raw: &E) -> bool {
raw.var(GMAIL_CLIENT_ID).is_some()
&& raw.var(GMAIL_CLIENT_SECRET).is_some()
&& raw.var(GMAIL_REFRESH_TOKEN).is_some()
}
pub fn validate_account(settings: &GmailSettings, name: &str) -> Result<()> {
if settings.accounts.contains_key(name) {
return Ok(());
}
Err(anyhow::anyhow!(
"unknown Gmail account '{name}'; known accounts: {}",
known_accounts(settings)
))
}
fn known_accounts(settings: &GmailSettings) -> String {
if settings.accounts.is_empty() {
"(none)".to_string()
} else {
let mut names: Vec<&str> = settings.accounts.keys().map(String::as_str).collect();
names.sort_unstable();
names.join(", ")
}
}
fn resolve_default_account(settings: &GmailSettings) -> Result<String> {
if let Some(default) = &settings.default_account {
return if settings.accounts.contains_key(default) {
Ok(default.clone())
} else {
Err(anyhow::anyhow!(
"configured default Gmail account '{default}' no longer exists; known accounts: {}",
known_accounts(settings)
))
};
}
let mut names = settings.accounts.keys();
if let (Some(sole), None) = (names.next(), names.next()) {
return Ok(sole.clone());
}
Err(anyhow::anyhow!(
"multiple Gmail accounts configured and no default set; pass --account or run \
`gmail account set-default <name>`"
))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AccountSummary {
pub name: String,
pub email_address: Option<String>,
pub scope: Option<String>,
pub is_default: bool,
}
pub fn list_accounts(settings: &GmailSettings) -> Vec<AccountSummary> {
let mut names: Vec<&String> = settings.accounts.keys().collect();
names.sort();
names
.into_iter()
.map(|name| {
let account = &settings.accounts[name];
AccountSummary {
name: name.clone(),
email_address: account.email_address.clone(),
scope: account.scope.clone(),
is_default: settings.default_account.as_deref() == Some(name.as_str()),
}
})
.collect()
}
pub fn is_first_legacy_to_named_transition(
settings: &GmailSettings,
has_legacy_credentials: bool,
) -> bool {
settings.accounts.is_empty() && has_legacy_credentials
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::test_support::env::MapEnv;
use crate::utils::settings::GmailAccountSettings;
use std::collections::HashMap;
fn account(email: Option<&str>, scope: Option<&str>) -> GmailAccountSettings {
GmailAccountSettings {
client_id: Some("id".to_string()),
client_secret: Some("secret".to_string()),
refresh_token: Some("token".to_string()),
scope: scope.map(str::to_string),
email_address: email.map(str::to_string),
chrome_profile_from_email: false,
browser_command: None,
}
}
fn settings_with(
default_account: Option<&str>,
accounts: &[(&str, GmailAccountSettings)],
) -> GmailSettings {
GmailSettings {
default_account: default_account.map(str::to_string),
accounts: accounts
.iter()
.map(|(name, acc)| {
(
(*name).to_string(),
GmailAccountSettings {
client_id: acc.client_id.clone(),
client_secret: acc.client_secret.clone(),
refresh_token: acc.refresh_token.clone(),
scope: acc.scope.clone(),
email_address: acc.email_address.clone(),
chrome_profile_from_email: acc.chrome_profile_from_email,
browser_command: acc.browser_command.clone(),
},
)
})
.collect::<HashMap<_, _>>(),
}
}
#[test]
fn active_gmail_account_from_reads_and_trims_empty() {
assert_eq!(
active_gmail_account_from(&MapEnv::new().with(GMAIL_ACCOUNT_ENV, "work")),
Some("work".to_string())
);
assert_eq!(
active_gmail_account_from(&MapEnv::new().with(GMAIL_ACCOUNT_ENV, "")),
None
);
assert_eq!(active_gmail_account_from(&MapEnv::new()), None);
}
#[test]
fn resolve_literal_env_bypasses_account_resolution() {
let raw = MapEnv::new()
.with(GMAIL_CLIENT_ID, "id")
.with(GMAIL_CLIENT_SECRET, "secret")
.with(GMAIL_REFRESH_TOKEN, "token");
let settings = settings_with(
None,
&[("a", account(None, None)), ("b", account(None, None))],
);
assert_eq!(
resolve_account(&raw, &settings, Some("a")).unwrap(),
ResolvedAccount::Legacy
);
assert_eq!(
resolve_account(&raw, &settings, None).unwrap(),
ResolvedAccount::Legacy
);
}
#[test]
fn resolve_literal_env_requires_all_three_keys() {
let raw = MapEnv::new()
.with(GMAIL_CLIENT_ID, "id")
.with(GMAIL_CLIENT_SECRET, "secret");
let settings = settings_with(None, &[("work", account(None, None))]);
assert_eq!(
resolve_account(&raw, &settings, None).unwrap(),
ResolvedAccount::Named("work".to_string())
);
}
#[test]
fn resolve_explicit_account_selects_named() {
let settings = settings_with(None, &[("work", account(None, None))]);
assert_eq!(
resolve_account(&MapEnv::new(), &settings, Some("work")).unwrap(),
ResolvedAccount::Named("work".to_string())
);
}
#[test]
fn resolve_explicit_unknown_account_errors_with_sorted_list() {
let settings = settings_with(
None,
&[
("work", account(None, None)),
("personal", account(None, None)),
],
);
let err = resolve_account(&MapEnv::new(), &settings, Some("bogus")).unwrap_err();
assert_eq!(
err.to_string(),
"unknown Gmail account 'bogus'; known accounts: personal, work"
);
}
#[test]
fn resolve_explicit_unknown_account_reports_none_when_empty() {
let settings = settings_with(None, &[]);
let err = resolve_account(&MapEnv::new(), &settings, Some("bogus")).unwrap_err();
assert_eq!(
err.to_string(),
"unknown Gmail account 'bogus'; known accounts: (none)"
);
}
#[test]
fn resolve_for_write_explicit_new_name_is_accepted_without_validation() {
let settings = settings_with(None, &[]);
assert_eq!(
resolve_account_for_write(&MapEnv::new(), &settings, Some("brand-new")).unwrap(),
ResolvedAccount::Named("brand-new".to_string())
);
}
#[test]
fn resolve_for_write_explicit_existing_name_still_selects_it() {
let settings = settings_with(None, &[("work", account(None, None))]);
assert_eq!(
resolve_account_for_write(&MapEnv::new(), &settings, Some("work")).unwrap(),
ResolvedAccount::Named("work".to_string())
);
}
#[test]
fn resolve_for_write_literal_env_still_bypasses_account_resolution() {
let raw = MapEnv::new()
.with(GMAIL_CLIENT_ID, "id")
.with(GMAIL_CLIENT_SECRET, "secret")
.with(GMAIL_REFRESH_TOKEN, "token");
let settings = settings_with(None, &[]);
assert_eq!(
resolve_account_for_write(&raw, &settings, Some("brand-new")).unwrap(),
ResolvedAccount::Legacy
);
}
#[test]
fn resolve_for_write_no_explicit_matches_read_side_fallback_rules() {
let sole = settings_with(None, &[("work", account(None, None))]);
assert_eq!(
resolve_account_for_write(&MapEnv::new(), &sole, None).unwrap(),
ResolvedAccount::Named("work".to_string())
);
let empty = settings_with(None, &[]);
assert_eq!(
resolve_account_for_write(&MapEnv::new(), &empty, None).unwrap(),
ResolvedAccount::Legacy
);
let ambiguous = settings_with(
None,
&[
("work", account(None, None)),
("personal", account(None, None)),
],
);
assert!(resolve_account_for_write(&MapEnv::new(), &ambiguous, None).is_err());
}
#[test]
fn resolve_empty_accounts_is_legacy() {
let settings = settings_with(None, &[]);
assert_eq!(
resolve_account(&MapEnv::new(), &settings, None).unwrap(),
ResolvedAccount::Legacy
);
}
#[test]
fn resolve_no_explicit_uses_valid_default() {
let settings = settings_with(
Some("work"),
&[
("work", account(None, None)),
("personal", account(None, None)),
],
);
assert_eq!(
resolve_account(&MapEnv::new(), &settings, None).unwrap(),
ResolvedAccount::Named("work".to_string())
);
}
#[test]
fn resolve_no_explicit_stale_default_errors() {
let settings = settings_with(Some("gone"), &[("work", account(None, None))]);
let err = resolve_account(&MapEnv::new(), &settings, None).unwrap_err();
assert_eq!(
err.to_string(),
"configured default Gmail account 'gone' no longer exists; known accounts: work"
);
}
#[test]
fn resolve_no_explicit_no_default_falls_back_to_sole_account() {
let settings = settings_with(None, &[("work", account(None, None))]);
assert_eq!(
resolve_account(&MapEnv::new(), &settings, None).unwrap(),
ResolvedAccount::Named("work".to_string())
);
}
#[test]
fn resolve_no_explicit_no_default_multiple_accounts_errors() {
let settings = settings_with(
None,
&[
("work", account(None, None)),
("personal", account(None, None)),
],
);
let err = resolve_account(&MapEnv::new(), &settings, None).unwrap_err();
assert_eq!(
err.to_string(),
"multiple Gmail accounts configured and no default set; pass --account or run \
`gmail account set-default <name>`"
);
}
#[test]
fn list_accounts_sorted_and_marks_default() {
let settings = settings_with(
Some("work"),
&[
("personal", account(Some("me@gmail.com"), Some("readonly"))),
("work", account(Some("me@work.com"), Some("modify"))),
],
);
let rows = list_accounts(&settings);
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].name, "personal");
assert_eq!(rows[0].email_address.as_deref(), Some("me@gmail.com"));
assert_eq!(rows[0].scope.as_deref(), Some("readonly"));
assert!(!rows[0].is_default);
assert_eq!(rows[1].name, "work");
assert!(rows[1].is_default);
}
#[test]
fn list_accounts_empty_when_no_accounts() {
assert!(list_accounts(&settings_with(None, &[])).is_empty());
}
#[test]
fn is_first_legacy_to_named_transition_true_only_when_empty_and_legacy_present() {
let empty = settings_with(None, &[]);
let non_empty = settings_with(None, &[("work", account(None, None))]);
assert!(is_first_legacy_to_named_transition(&empty, true));
assert!(!is_first_legacy_to_named_transition(&empty, false));
assert!(!is_first_legacy_to_named_transition(&non_empty, true));
}
}