1use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum TokenStorage {
17 #[default]
18 Keychain,
19 File,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28pub struct Account {
29 pub email: String,
30 pub tenant_id: String,
31 pub home_account_id: String,
32 pub added_at: DateTime<Utc>,
33 #[serde(default)]
34 pub storage: TokenStorage,
35}
36
37impl Account {
38 pub const PERSONAL_MSA_TENANT: &'static str = "9188040d-6c67-4c5b-b112-36a304b66dad";
41
42 pub fn is_personal(&self) -> bool {
44 self.tenant_id == Self::PERSONAL_MSA_TENANT
45 }
46
47 pub fn tenant_label(&self) -> String {
49 if self.is_personal() {
50 "personal MSA".to_string()
51 } else {
52 let prefix: String = self.tenant_id.chars().take(8).collect();
53 format!("{prefix}…")
54 }
55 }
56}
57
58#[cfg(test)]
59mod tests {
60 use super::*;
61
62 fn make_account(tenant_id: &str) -> Account {
63 Account {
64 email: "x@example.com".into(),
65 tenant_id: tenant_id.into(),
66 home_account_id: "home".into(),
67 added_at: DateTime::parse_from_rfc3339("2026-05-13T22:00:00Z")
68 .unwrap()
69 .to_utc(),
70 storage: TokenStorage::default(),
71 }
72 }
73
74 #[test]
75 fn token_storage_default_is_keychain() {
76 assert!(matches!(TokenStorage::default(), TokenStorage::Keychain));
77 }
78
79 #[test]
80 fn token_storage_serializes_lowercase() {
81 assert_eq!(
82 serde_json::to_string(&TokenStorage::Keychain).unwrap(),
83 "\"keychain\""
84 );
85 assert_eq!(
86 serde_json::to_string(&TokenStorage::File).unwrap(),
87 "\"file\""
88 );
89 }
90
91 #[test]
92 fn account_without_storage_field_deserializes_as_keychain() {
93 let yaml = r#"
94email: a@b.com
95tenant_id: tid
96home_account_id: hid
97added_at: "2026-05-13T22:00:00Z"
98"#;
99 let a: Account = serde_yaml::from_str(yaml).unwrap();
100 assert!(matches!(a.storage, TokenStorage::Keychain));
101 }
102
103 #[test]
104 fn personal_msa_tenant_is_recognised() {
105 assert!(make_account(Account::PERSONAL_MSA_TENANT).is_personal());
106 }
107
108 #[test]
109 fn org_tenant_is_not_personal() {
110 assert!(!make_account("11111111-2222-3333-4444-555555555555").is_personal());
111 }
112
113 #[test]
114 fn tenant_label_for_msa() {
115 assert_eq!(
116 make_account(Account::PERSONAL_MSA_TENANT).tenant_label(),
117 "personal MSA"
118 );
119 }
120
121 #[test]
122 fn tenant_label_for_org_truncates_to_8_chars() {
123 assert_eq!(
124 make_account("11111111-2222-3333-4444-555555555555").tenant_label(),
125 "11111111…"
126 );
127 }
128}