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 {
52 if self.is_personal() {
53 "personal MSA".to_string()
54 } else if self.tenant_id.is_empty() {
55 "(unknown)".to_string()
56 } else {
57 let prefix: String = self.tenant_id.chars().take(8).collect();
58 format!("{prefix}…")
59 }
60 }
61
62 pub fn provider_label(&self) -> &'static str {
67 if self.is_personal() {
68 "Outlook"
69 } else {
70 "M365"
71 }
72 }
73
74 pub fn provider_id(&self) -> &'static str {
77 if self.is_personal() {
78 "outlook"
79 } else {
80 "m365"
81 }
82 }
83}
84
85#[cfg(test)]
86mod tests {
87 use super::*;
88
89 fn make_account(tenant_id: &str) -> Account {
90 Account {
91 email: "x@example.com".into(),
92 tenant_id: tenant_id.into(),
93 home_account_id: "home".into(),
94 added_at: DateTime::parse_from_rfc3339("2026-05-13T22:00:00Z")
95 .unwrap()
96 .to_utc(),
97 storage: TokenStorage::default(),
98 }
99 }
100
101 #[test]
102 fn token_storage_default_is_keychain() {
103 assert!(matches!(TokenStorage::default(), TokenStorage::Keychain));
104 }
105
106 #[test]
107 fn token_storage_serializes_lowercase() {
108 assert_eq!(
109 serde_json::to_string(&TokenStorage::Keychain).unwrap(),
110 "\"keychain\""
111 );
112 assert_eq!(
113 serde_json::to_string(&TokenStorage::File).unwrap(),
114 "\"file\""
115 );
116 }
117
118 #[test]
119 fn account_without_storage_field_deserializes_as_keychain() {
120 let yaml = r#"
121email: a@b.com
122tenant_id: tid
123home_account_id: hid
124added_at: "2026-05-13T22:00:00Z"
125"#;
126 let a: Account = serde_yaml::from_str(yaml).unwrap();
127 assert!(matches!(a.storage, TokenStorage::Keychain));
128 }
129
130 #[test]
131 fn personal_msa_tenant_is_recognised() {
132 assert!(make_account(Account::PERSONAL_MSA_TENANT).is_personal());
133 }
134
135 #[test]
136 fn org_tenant_is_not_personal() {
137 assert!(!make_account("11111111-2222-3333-4444-555555555555").is_personal());
138 }
139
140 #[test]
141 fn tenant_label_for_msa() {
142 assert_eq!(
143 make_account(Account::PERSONAL_MSA_TENANT).tenant_label(),
144 "personal MSA"
145 );
146 }
147
148 #[test]
149 fn tenant_label_for_org_truncates_to_8_chars() {
150 assert_eq!(
151 make_account("11111111-2222-3333-4444-555555555555").tenant_label(),
152 "11111111…"
153 );
154 }
155
156 #[test]
157 fn provider_label_for_personal_msa_is_outlook() {
158 let a = make_account(Account::PERSONAL_MSA_TENANT);
159 assert_eq!(a.provider_label(), "Outlook");
160 assert_eq!(a.provider_id(), "outlook");
161 }
162
163 #[test]
164 fn provider_label_for_org_tenant_is_m365() {
165 let a = make_account("11111111-2222-3333-4444-555555555555");
166 assert_eq!(a.provider_label(), "M365");
167 assert_eq!(a.provider_id(), "m365");
168 }
169}