1use anyhow::Result;
14use serde::Serialize;
15
16use crate::gmail::auth::{GMAIL_CLIENT_ID, GMAIL_CLIENT_SECRET, GMAIL_REFRESH_TOKEN};
17use crate::utils::env::EnvSource;
18use crate::utils::settings::GmailSettings;
19
20pub const GMAIL_ACCOUNT_ENV: &str = "OMNI_DEV_GMAIL_ACCOUNT";
24
25pub fn active_gmail_account_from<E: EnvSource>(raw: &E) -> Option<String> {
32 raw.var(GMAIL_ACCOUNT_ENV).filter(|s| !s.is_empty())
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum ResolvedAccount {
39 Named(String),
41 Legacy,
45}
46
47pub fn resolve_account<E: EnvSource>(
73 raw: &E,
74 settings: &GmailSettings,
75 explicit: Option<&str>,
76) -> Result<ResolvedAccount> {
77 if has_literal_credentials(raw) {
78 return Ok(ResolvedAccount::Legacy);
79 }
80 if let Some(name) = explicit {
81 validate_account(settings, name)?;
82 return Ok(ResolvedAccount::Named(name.to_string()));
83 }
84 if settings.accounts.is_empty() {
85 return Ok(ResolvedAccount::Legacy);
86 }
87 resolve_default_account(settings).map(ResolvedAccount::Named)
88}
89
90pub fn resolve_account_for_write<E: EnvSource>(
102 raw: &E,
103 settings: &GmailSettings,
104 explicit: Option<&str>,
105) -> Result<ResolvedAccount> {
106 if has_literal_credentials(raw) {
107 return Ok(ResolvedAccount::Legacy);
108 }
109 if let Some(name) = explicit {
110 return Ok(ResolvedAccount::Named(name.to_string()));
111 }
112 if settings.accounts.is_empty() {
113 return Ok(ResolvedAccount::Legacy);
114 }
115 resolve_default_account(settings).map(ResolvedAccount::Named)
116}
117
118fn has_literal_credentials<E: EnvSource>(raw: &E) -> bool {
121 raw.var(GMAIL_CLIENT_ID).is_some()
122 && raw.var(GMAIL_CLIENT_SECRET).is_some()
123 && raw.var(GMAIL_REFRESH_TOKEN).is_some()
124}
125
126pub fn validate_account(settings: &GmailSettings, name: &str) -> Result<()> {
134 if settings.accounts.contains_key(name) {
135 return Ok(());
136 }
137 Err(anyhow::anyhow!(
138 "unknown Gmail account '{name}'; known accounts: {}",
139 known_accounts(settings)
140 ))
141}
142
143fn known_accounts(settings: &GmailSettings) -> String {
146 if settings.accounts.is_empty() {
147 "(none)".to_string()
148 } else {
149 let mut names: Vec<&str> = settings.accounts.keys().map(String::as_str).collect();
150 names.sort_unstable();
151 names.join(", ")
152 }
153}
154
155fn resolve_default_account(settings: &GmailSettings) -> Result<String> {
159 if let Some(default) = &settings.default_account {
160 return if settings.accounts.contains_key(default) {
161 Ok(default.clone())
162 } else {
163 Err(anyhow::anyhow!(
164 "configured default Gmail account '{default}' no longer exists; known accounts: {}",
165 known_accounts(settings)
166 ))
167 };
168 }
169 let mut names = settings.accounts.keys();
170 if let (Some(sole), None) = (names.next(), names.next()) {
171 return Ok(sole.clone());
172 }
173 Err(anyhow::anyhow!(
174 "multiple Gmail accounts configured and no default set; pass --account or run \
175 `gmail account set-default <name>`"
176 ))
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
182pub struct AccountSummary {
183 pub name: String,
185 pub email_address: Option<String>,
187 pub scope: Option<String>,
189 pub is_default: bool,
191}
192
193pub fn list_accounts(settings: &GmailSettings) -> Vec<AccountSummary> {
195 let mut names: Vec<&String> = settings.accounts.keys().collect();
196 names.sort();
197 names
198 .into_iter()
199 .map(|name| {
200 let account = &settings.accounts[name];
201 AccountSummary {
202 name: name.clone(),
203 email_address: account.email_address.clone(),
204 scope: account.scope.clone(),
205 is_default: settings.default_account.as_deref() == Some(name.as_str()),
206 }
207 })
208 .collect()
209}
210
211pub fn is_first_legacy_to_named_transition(
221 settings: &GmailSettings,
222 has_legacy_credentials: bool,
223) -> bool {
224 settings.accounts.is_empty() && has_legacy_credentials
225}
226
227#[cfg(test)]
228#[allow(clippy::unwrap_used)]
229mod tests {
230 use super::*;
231 use crate::test_support::env::MapEnv;
232 use crate::utils::settings::GmailAccountSettings;
233 use std::collections::HashMap;
234
235 fn account(email: Option<&str>, scope: Option<&str>) -> GmailAccountSettings {
236 GmailAccountSettings {
237 client_id: Some("id".to_string()),
238 client_secret: Some("secret".to_string()),
239 refresh_token: Some("token".to_string()),
240 scope: scope.map(str::to_string),
241 email_address: email.map(str::to_string),
242 chrome_profile_from_email: false,
243 browser_command: None,
244 }
245 }
246
247 fn settings_with(
248 default_account: Option<&str>,
249 accounts: &[(&str, GmailAccountSettings)],
250 ) -> GmailSettings {
251 GmailSettings {
252 default_account: default_account.map(str::to_string),
253 accounts: accounts
254 .iter()
255 .map(|(name, acc)| {
256 (
257 (*name).to_string(),
258 GmailAccountSettings {
259 client_id: acc.client_id.clone(),
260 client_secret: acc.client_secret.clone(),
261 refresh_token: acc.refresh_token.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 },
267 )
268 })
269 .collect::<HashMap<_, _>>(),
270 }
271 }
272
273 #[test]
274 fn active_gmail_account_from_reads_and_trims_empty() {
275 assert_eq!(
276 active_gmail_account_from(&MapEnv::new().with(GMAIL_ACCOUNT_ENV, "work")),
277 Some("work".to_string())
278 );
279 assert_eq!(
280 active_gmail_account_from(&MapEnv::new().with(GMAIL_ACCOUNT_ENV, "")),
281 None
282 );
283 assert_eq!(active_gmail_account_from(&MapEnv::new()), None);
284 }
285
286 #[test]
287 fn resolve_literal_env_bypasses_account_resolution() {
288 let raw = MapEnv::new()
289 .with(GMAIL_CLIENT_ID, "id")
290 .with(GMAIL_CLIENT_SECRET, "secret")
291 .with(GMAIL_REFRESH_TOKEN, "token");
292 let settings = settings_with(
295 None,
296 &[("a", account(None, None)), ("b", account(None, None))],
297 );
298 assert_eq!(
299 resolve_account(&raw, &settings, Some("a")).unwrap(),
300 ResolvedAccount::Legacy
301 );
302 assert_eq!(
303 resolve_account(&raw, &settings, None).unwrap(),
304 ResolvedAccount::Legacy
305 );
306 }
307
308 #[test]
309 fn resolve_literal_env_requires_all_three_keys() {
310 let raw = MapEnv::new()
311 .with(GMAIL_CLIENT_ID, "id")
312 .with(GMAIL_CLIENT_SECRET, "secret");
313 let settings = settings_with(None, &[("work", account(None, None))]);
314 assert_eq!(
317 resolve_account(&raw, &settings, None).unwrap(),
318 ResolvedAccount::Named("work".to_string())
319 );
320 }
321
322 #[test]
323 fn resolve_explicit_account_selects_named() {
324 let settings = settings_with(None, &[("work", account(None, None))]);
325 assert_eq!(
326 resolve_account(&MapEnv::new(), &settings, Some("work")).unwrap(),
327 ResolvedAccount::Named("work".to_string())
328 );
329 }
330
331 #[test]
332 fn resolve_explicit_unknown_account_errors_with_sorted_list() {
333 let settings = settings_with(
334 None,
335 &[
336 ("work", account(None, None)),
337 ("personal", account(None, None)),
338 ],
339 );
340 let err = resolve_account(&MapEnv::new(), &settings, Some("bogus")).unwrap_err();
341 assert_eq!(
342 err.to_string(),
343 "unknown Gmail account 'bogus'; known accounts: personal, work"
344 );
345 }
346
347 #[test]
348 fn resolve_explicit_unknown_account_reports_none_when_empty() {
349 let settings = settings_with(None, &[]);
350 let err = resolve_account(&MapEnv::new(), &settings, Some("bogus")).unwrap_err();
351 assert_eq!(
352 err.to_string(),
353 "unknown Gmail account 'bogus'; known accounts: (none)"
354 );
355 }
356
357 #[test]
360 fn resolve_for_write_explicit_new_name_is_accepted_without_validation() {
361 let settings = settings_with(None, &[]);
364 assert_eq!(
365 resolve_account_for_write(&MapEnv::new(), &settings, Some("brand-new")).unwrap(),
366 ResolvedAccount::Named("brand-new".to_string())
367 );
368 }
369
370 #[test]
371 fn resolve_for_write_explicit_existing_name_still_selects_it() {
372 let settings = settings_with(None, &[("work", account(None, None))]);
373 assert_eq!(
374 resolve_account_for_write(&MapEnv::new(), &settings, Some("work")).unwrap(),
375 ResolvedAccount::Named("work".to_string())
376 );
377 }
378
379 #[test]
380 fn resolve_for_write_literal_env_still_bypasses_account_resolution() {
381 let raw = MapEnv::new()
382 .with(GMAIL_CLIENT_ID, "id")
383 .with(GMAIL_CLIENT_SECRET, "secret")
384 .with(GMAIL_REFRESH_TOKEN, "token");
385 let settings = settings_with(None, &[]);
386 assert_eq!(
387 resolve_account_for_write(&raw, &settings, Some("brand-new")).unwrap(),
388 ResolvedAccount::Legacy
389 );
390 }
391
392 #[test]
393 fn resolve_for_write_no_explicit_matches_read_side_fallback_rules() {
394 let sole = settings_with(None, &[("work", account(None, None))]);
397 assert_eq!(
398 resolve_account_for_write(&MapEnv::new(), &sole, None).unwrap(),
399 ResolvedAccount::Named("work".to_string())
400 );
401
402 let empty = settings_with(None, &[]);
403 assert_eq!(
404 resolve_account_for_write(&MapEnv::new(), &empty, None).unwrap(),
405 ResolvedAccount::Legacy
406 );
407
408 let ambiguous = settings_with(
409 None,
410 &[
411 ("work", account(None, None)),
412 ("personal", account(None, None)),
413 ],
414 );
415 assert!(resolve_account_for_write(&MapEnv::new(), &ambiguous, None).is_err());
416 }
417
418 #[test]
419 fn resolve_empty_accounts_is_legacy() {
420 let settings = settings_with(None, &[]);
421 assert_eq!(
422 resolve_account(&MapEnv::new(), &settings, None).unwrap(),
423 ResolvedAccount::Legacy
424 );
425 }
426
427 #[test]
428 fn resolve_no_explicit_uses_valid_default() {
429 let settings = settings_with(
430 Some("work"),
431 &[
432 ("work", account(None, None)),
433 ("personal", account(None, None)),
434 ],
435 );
436 assert_eq!(
437 resolve_account(&MapEnv::new(), &settings, None).unwrap(),
438 ResolvedAccount::Named("work".to_string())
439 );
440 }
441
442 #[test]
443 fn resolve_no_explicit_stale_default_errors() {
444 let settings = settings_with(Some("gone"), &[("work", account(None, None))]);
445 let err = resolve_account(&MapEnv::new(), &settings, None).unwrap_err();
446 assert_eq!(
447 err.to_string(),
448 "configured default Gmail account 'gone' no longer exists; known accounts: work"
449 );
450 }
451
452 #[test]
453 fn resolve_no_explicit_no_default_falls_back_to_sole_account() {
454 let settings = settings_with(None, &[("work", account(None, None))]);
455 assert_eq!(
456 resolve_account(&MapEnv::new(), &settings, None).unwrap(),
457 ResolvedAccount::Named("work".to_string())
458 );
459 }
460
461 #[test]
462 fn resolve_no_explicit_no_default_multiple_accounts_errors() {
463 let settings = settings_with(
464 None,
465 &[
466 ("work", account(None, None)),
467 ("personal", account(None, None)),
468 ],
469 );
470 let err = resolve_account(&MapEnv::new(), &settings, None).unwrap_err();
471 assert_eq!(
472 err.to_string(),
473 "multiple Gmail accounts configured and no default set; pass --account or run \
474 `gmail account set-default <name>`"
475 );
476 }
477
478 #[test]
479 fn list_accounts_sorted_and_marks_default() {
480 let settings = settings_with(
481 Some("work"),
482 &[
483 ("personal", account(Some("me@gmail.com"), Some("readonly"))),
484 ("work", account(Some("me@work.com"), Some("modify"))),
485 ],
486 );
487 let rows = list_accounts(&settings);
488 assert_eq!(rows.len(), 2);
489 assert_eq!(rows[0].name, "personal");
490 assert_eq!(rows[0].email_address.as_deref(), Some("me@gmail.com"));
491 assert_eq!(rows[0].scope.as_deref(), Some("readonly"));
492 assert!(!rows[0].is_default);
493 assert_eq!(rows[1].name, "work");
494 assert!(rows[1].is_default);
495 }
496
497 #[test]
498 fn list_accounts_empty_when_no_accounts() {
499 assert!(list_accounts(&settings_with(None, &[])).is_empty());
500 }
501
502 #[test]
503 fn is_first_legacy_to_named_transition_true_only_when_empty_and_legacy_present() {
504 let empty = settings_with(None, &[]);
505 let non_empty = settings_with(None, &[("work", account(None, None))]);
506 assert!(is_first_legacy_to_named_transition(&empty, true));
507 assert!(!is_first_legacy_to_named_transition(&empty, false));
508 assert!(!is_first_legacy_to_named_transition(&non_empty, true));
509 }
510}