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