1use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13
14use serde::{Deserialize, Serialize};
15
16use crate::error::SpendPanelError;
17
18pub const DEFAULT_ACCOUNT: &str = "default";
20
21#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
23pub struct AccountSettings {
24 #[serde(skip_serializing_if = "Option::is_none")]
26 pub enabled: Option<bool>,
27 #[serde(skip_serializing_if = "Option::is_none")]
29 pub label: Option<String>,
30 #[serde(skip_serializing_if = "Option::is_none")]
32 pub token: Option<String>,
33 #[serde(default, skip_serializing_if = "Vec::is_empty")]
37 pub workspaces: Vec<String>,
38 #[serde(default, flatten)]
41 pub config: HashMap<String, String>,
42}
43
44impl AccountSettings {
45 pub fn is_empty(&self) -> bool {
47 self.enabled.is_none()
48 && self.label.is_none()
49 && self.token.is_none()
50 && self.workspaces.is_empty()
51 && self.config.is_empty()
52 }
53}
54
55#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
57pub struct ProviderSettings {
58 #[serde(skip_serializing_if = "Option::is_none")]
61 pub enabled: Option<bool>,
62 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
64 pub accounts: HashMap<String, AccountSettings>,
65}
66
67impl ProviderSettings {
68 pub fn is_empty(&self) -> bool {
70 self.enabled.is_none() && self.accounts.is_empty()
71 }
72}
73
74#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
76pub struct AppConfig {
77 #[serde(default)]
78 pub providers: HashMap<String, ProviderSettings>,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum ProviderState {
84 Enabled,
86 Disabled,
88 AutoEnabled,
90 AutoDisabled,
92}
93
94impl ProviderState {
95 pub fn is_enabled(self) -> bool {
96 matches!(self, Self::Enabled | Self::AutoEnabled)
97 }
98}
99
100impl AppConfig {
101 pub fn default_path() -> Option<PathBuf> {
104 if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME")
105 && !xdg.is_empty()
106 {
107 return Some(PathBuf::from(xdg).join("usage-monitor/config.toml"));
108 }
109 std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config/usage-monitor/config.toml"))
110 }
111
112 pub fn load_from_path(path: &Path) -> Result<Self, SpendPanelError> {
114 let raw = match std::fs::read_to_string(path) {
115 Ok(raw) => raw,
116 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
117 Err(e) => {
118 return Err(SpendPanelError::ConfigError(format!(
119 "cannot read config at {}: {}",
120 path.display(),
121 e
122 )));
123 }
124 };
125 toml::from_str(&raw)
126 .map_err(|e| SpendPanelError::ConfigError(format!("invalid config: {}", e)))
127 }
128
129 pub fn load() -> Result<Self, SpendPanelError> {
131 match Self::default_path() {
132 Some(path) => Self::load_from_path(&path),
133 None => Ok(Self::default()),
134 }
135 }
136
137 pub fn save_to_path(&self, path: &Path) -> Result<(), SpendPanelError> {
139 if let Some(parent) = path.parent() {
140 std::fs::create_dir_all(parent)
141 .map_err(|e| SpendPanelError::ConfigError(format!("create config dir: {}", e)))?;
142 }
143 let raw = toml::to_string_pretty(self)
144 .map_err(|e| SpendPanelError::ConfigError(format!("serialize config: {}", e)))?;
145 std::fs::write(path, raw)
146 .map_err(|e| SpendPanelError::ConfigError(format!("write config: {}", e)))
147 }
148
149 pub fn provider_enabled(&self, id: &str) -> Option<bool> {
155 self.providers.get(id).and_then(|p| p.enabled)
156 }
157
158 pub fn set_provider_enabled(&mut self, id: &str, enabled: bool) {
160 self.providers.entry(id.to_string()).or_default().enabled = Some(enabled);
161 }
162
163 pub fn clear_provider_enabled(&mut self, id: &str) {
165 if let Some(settings) = self.providers.get_mut(id) {
166 settings.enabled = None;
167 self.prune_provider(id);
168 }
169 }
170
171 pub fn resolve_state(&self, id: &str, credentials_detected: bool) -> ProviderState {
174 match self.provider_enabled(id) {
175 Some(true) => ProviderState::Enabled,
176 Some(false) => ProviderState::Disabled,
177 None if credentials_detected || self.has_accounts(id) => ProviderState::AutoEnabled,
178 None => ProviderState::AutoDisabled,
179 }
180 }
181
182 pub fn has_accounts(&self, id: &str) -> bool {
188 self.providers
189 .get(id)
190 .is_some_and(|p| !p.accounts.is_empty())
191 }
192
193 pub fn account_ids(&self, id: &str) -> Vec<String> {
195 let mut ids: Vec<String> = self
196 .providers
197 .get(id)
198 .map(|p| p.accounts.keys().cloned().collect())
199 .unwrap_or_default();
200 ids.sort();
201 ids
202 }
203
204 pub fn account(&self, id: &str, account: &str) -> Option<&AccountSettings> {
206 self.providers.get(id).and_then(|p| p.accounts.get(account))
207 }
208
209 pub fn add_account(&mut self, id: &str, account: &str, label: Option<&str>) -> bool {
212 let accounts = &mut self.providers.entry(id.to_string()).or_default().accounts;
213 let created = !accounts.contains_key(account);
214 let entry = accounts.entry(account.to_string()).or_default();
215 if let Some(label) = label {
216 entry.label = Some(label.to_string());
217 }
218 created
219 }
220
221 pub fn remove_account(&mut self, id: &str, account: &str) -> bool {
223 let existed = self
224 .providers
225 .get_mut(id)
226 .is_some_and(|p| p.accounts.remove(account).is_some());
227 if existed {
228 self.prune_provider(id);
229 }
230 existed
231 }
232
233 pub fn set_account_label(&mut self, id: &str, account: &str, label: &str) {
235 self.account_entry(id, account).label = Some(label.to_string());
236 }
237
238 pub fn account_label(&self, id: &str, account: &str) -> Option<&str> {
240 self.account(id, account).and_then(|a| a.label.as_deref())
241 }
242
243 pub fn set_account_config(&mut self, id: &str, account: &str, key: &str, value: &str) {
246 let entry = self.account_entry(id, account);
247 if key == "token" {
248 entry.token = Some(value.to_string());
249 } else {
250 entry.config.insert(key.to_string(), value.to_string());
251 }
252 }
253
254 pub fn unset_account_config(&mut self, id: &str, account: &str, key: &str) {
256 if let Some(settings) = self.providers.get_mut(id)
257 && let Some(acct) = settings.accounts.get_mut(account)
258 {
259 if key == "token" {
260 acct.token = None;
261 } else {
262 acct.config.remove(key);
263 }
264 }
265 self.prune_account(id, account);
266 }
267
268 pub fn account_config(&self, id: &str, account: &str) -> Option<&HashMap<String, String>> {
270 self.account(id, account).map(|a| &a.config)
271 }
272
273 pub fn account_token(&self, id: &str, account: &str) -> Option<&str> {
275 self.account(id, account).and_then(|a| a.token.as_deref())
276 }
277
278 pub fn account_workspaces(&self, id: &str, account: &str) -> &[String] {
280 self.account(id, account)
281 .map(|a| a.workspaces.as_slice())
282 .unwrap_or(&[])
283 }
284
285 pub fn set_account_workspaces(&mut self, id: &str, account: &str, workspaces: Vec<String>) {
287 self.account_entry(id, account).workspaces = workspaces;
288 self.prune_account(id, account);
289 }
290
291 pub fn account_enabled(&self, id: &str, account: &str) -> Option<bool> {
293 self.account(id, account).and_then(|a| a.enabled)
294 }
295
296 pub fn set_account_enabled(&mut self, id: &str, account: &str, enabled: bool) {
298 self.account_entry(id, account).enabled = Some(enabled);
299 }
300
301 pub fn clear_account_enabled(&mut self, id: &str, account: &str) {
303 if let Some(settings) = self.providers.get_mut(id)
304 && let Some(acct) = settings.accounts.get_mut(account)
305 {
306 acct.enabled = None;
307 }
308 self.prune_account(id, account);
309 }
310
311 pub fn account_is_enabled(&self, id: &str, account: &str) -> bool {
313 self.account_enabled(id, account).unwrap_or(true)
314 }
315
316 fn account_entry(&mut self, id: &str, account: &str) -> &mut AccountSettings {
322 self.providers
323 .entry(id.to_string())
324 .or_default()
325 .accounts
326 .entry(account.to_string())
327 .or_default()
328 }
329
330 fn prune_account(&mut self, id: &str, account: &str) {
332 if let Some(settings) = self.providers.get_mut(id)
333 && settings
334 .accounts
335 .get(account)
336 .is_some_and(AccountSettings::is_empty)
337 {
338 settings.accounts.remove(account);
339 }
340 self.prune_provider(id);
341 }
342
343 fn prune_provider(&mut self, id: &str) {
345 if self
346 .providers
347 .get(id)
348 .is_some_and(ProviderSettings::is_empty)
349 {
350 self.providers.remove(id);
351 }
352 }
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 fn temp_config_path(name: &str) -> PathBuf {
360 std::env::temp_dir().join(format!(
361 "usage-monitor-config-{}-{}/config.toml",
362 name,
363 std::process::id()
364 ))
365 }
366
367 #[test]
368 fn test_missing_file_is_default() {
369 let cfg = AppConfig::load_from_path(Path::new("/nonexistent/config.toml")).unwrap();
370 assert!(cfg.providers.is_empty());
371 }
372
373 #[test]
374 fn test_roundtrip_save_load() {
375 let path = temp_config_path("roundtrip");
376 let mut cfg = AppConfig::default();
377 cfg.set_provider_enabled("claude", true);
378 cfg.set_provider_enabled("openai", false);
379 cfg.set_account_config("anthropic", DEFAULT_ACCOUNT, "api_key", "sk-ant-x");
380
381 cfg.set_account_config("opencode-go", DEFAULT_ACCOUNT, "token", "session=abc");
382 cfg.set_account_workspaces("opencode-go", DEFAULT_ACCOUNT, vec!["wrk_a".into()]);
383 cfg.set_account_config("claude", "work", "credentials_path", "/tmp/work.json");
384 cfg.set_account_label("claude", "work", "Work Claude");
385
386 cfg.save_to_path(&path).unwrap();
387 let loaded = AppConfig::load_from_path(&path).unwrap();
388 std::fs::remove_dir_all(path.parent().unwrap()).ok();
389
390 assert_eq!(loaded, cfg);
391 assert_eq!(loaded.provider_enabled("claude"), Some(true));
392 assert_eq!(loaded.provider_enabled("openai"), Some(false));
393 assert_eq!(loaded.provider_enabled("codex"), None);
394 assert_eq!(
395 loaded.account_config("anthropic", DEFAULT_ACCOUNT).unwrap()["api_key"],
396 "sk-ant-x"
397 );
398 assert_eq!(
399 loaded.account_token("opencode-go", DEFAULT_ACCOUNT),
400 Some("session=abc")
401 );
402 assert_eq!(
403 loaded.account_workspaces("opencode-go", DEFAULT_ACCOUNT),
404 ["wrk_a".to_string()]
405 );
406 assert_eq!(loaded.account_label("claude", "work"), Some("Work Claude"));
407 }
408
409 #[test]
410 fn test_parse_toml() {
411 let cfg: AppConfig = toml::from_str(
412 r#"
413 [providers.claude]
414 enabled = true
415
416 [providers.claude.accounts.personal]
417 label = "Personal"
418 credentials_path = "~/.claude/.credentials.json"
419
420 [providers.claude.accounts.work]
421 enabled = false
422 credentials_path = "/tmp/work.json"
423
424 [providers.openai]
425 enabled = false
426
427 [providers.opencode-go.accounts.default]
428 token = "session=abc"
429 workspaces = ["wrk_a", "wrk_b"]
430 "#,
431 )
432 .unwrap();
433 assert_eq!(cfg.provider_enabled("claude"), Some(true));
434 assert_eq!(cfg.provider_enabled("openai"), Some(false));
435 assert_eq!(cfg.account_label("claude", "personal"), Some("Personal"));
436 assert_eq!(
437 cfg.account_config("claude", "personal").unwrap()["credentials_path"],
438 "~/.claude/.credentials.json"
439 );
440 assert_eq!(cfg.account_enabled("claude", "work"), Some(false));
441 assert!(!cfg.account_is_enabled("claude", "work"));
442 assert!(cfg.account_is_enabled("claude", "personal"));
443 assert_eq!(
444 cfg.account_token("opencode-go", DEFAULT_ACCOUNT),
445 Some("session=abc")
446 );
447 assert_eq!(
448 cfg.account_workspaces("opencode-go", DEFAULT_ACCOUNT),
449 ["wrk_a".to_string(), "wrk_b".to_string()]
450 );
451 }
452
453 #[test]
454 fn test_resolve_state() {
455 let mut cfg = AppConfig::default();
456 cfg.set_provider_enabled("a", true);
457 cfg.set_provider_enabled("b", false);
458
459 assert_eq!(cfg.resolve_state("a", false), ProviderState::Enabled);
460 assert_eq!(cfg.resolve_state("b", true), ProviderState::Disabled);
461 assert_eq!(cfg.resolve_state("c", true), ProviderState::AutoEnabled);
462 assert_eq!(cfg.resolve_state("c", false), ProviderState::AutoDisabled);
463
464 cfg.set_account_config("d", "personal", "api_key", "x");
466 assert_eq!(cfg.resolve_state("d", false), ProviderState::AutoEnabled);
467 }
468
469 #[test]
470 fn test_account_config_set_get_unset() {
471 let mut cfg = AppConfig::default();
472 cfg.set_account_config("opencode-go", DEFAULT_ACCOUNT, "token", "session=abc");
474 assert_eq!(
475 cfg.account_token("opencode-go", DEFAULT_ACCOUNT),
476 Some("session=abc")
477 );
478 assert!(
479 cfg.account_config("opencode-go", DEFAULT_ACCOUNT)
480 .unwrap()
481 .is_empty()
482 );
483
484 cfg.unset_account_config("opencode-go", DEFAULT_ACCOUNT, "token");
485 assert!(cfg.account("opencode-go", DEFAULT_ACCOUNT).is_none());
487 assert!(!cfg.providers.contains_key("opencode-go"));
488
489 cfg.set_account_config("anthropic", DEFAULT_ACCOUNT, "api_key", "sk-x");
491 assert_eq!(
492 cfg.account_config("anthropic", DEFAULT_ACCOUNT).unwrap()["api_key"],
493 "sk-x"
494 );
495 cfg.unset_account_config("anthropic", DEFAULT_ACCOUNT, "api_key");
496 assert!(cfg.account("anthropic", DEFAULT_ACCOUNT).is_none());
497 }
498
499 #[test]
500 fn test_unset_account_config_keeps_other_settings() {
501 let mut cfg = AppConfig::default();
502 cfg.set_account_label("opencode-go", DEFAULT_ACCOUNT, "Main");
503 cfg.set_account_config("opencode-go", DEFAULT_ACCOUNT, "token", "x");
504 cfg.unset_account_config("opencode-go", DEFAULT_ACCOUNT, "token");
505 assert_eq!(
506 cfg.account_label("opencode-go", DEFAULT_ACCOUNT),
507 Some("Main")
508 );
509 }
510
511 #[test]
512 fn test_clear_provider_enabled_keeps_accounts() {
513 let mut cfg = AppConfig::default();
514 cfg.set_provider_enabled("claude", false);
515 cfg.set_account_config("claude", "work", "credentials_path", "/tmp/w.json");
516 cfg.clear_provider_enabled("claude");
517 assert_eq!(cfg.provider_enabled("claude"), None);
518 assert!(cfg.has_accounts("claude"));
520 }
521
522 #[test]
523 fn test_clear_provider_enabled_removes_empty_provider() {
524 let mut cfg = AppConfig::default();
525 cfg.set_provider_enabled("claude", false);
526 cfg.clear_provider_enabled("claude");
527 assert_eq!(cfg.provider_enabled("claude"), None);
528 assert!(!cfg.providers.contains_key("claude"));
529 }
530
531 #[test]
532 fn test_add_remove_account() {
533 let mut cfg = AppConfig::default();
534 assert!(cfg.add_account("claude", "work", Some("Work")));
535 assert!(!cfg.add_account("claude", "work", None)); assert_eq!(cfg.account_label("claude", "work"), Some("Work"));
537 assert_eq!(cfg.account_ids("claude"), vec!["work".to_string()]);
538
539 assert!(cfg.remove_account("claude", "work"));
540 assert!(!cfg.remove_account("claude", "work"));
541 assert!(!cfg.providers.contains_key("claude"));
542 }
543
544 #[test]
545 fn test_account_enabled_toggle() {
546 let mut cfg = AppConfig::default();
547 cfg.set_account_config("claude", "work", "credentials_path", "/tmp/w.json");
548 assert!(cfg.account_is_enabled("claude", "work"));
549 cfg.set_account_enabled("claude", "work", false);
550 assert!(!cfg.account_is_enabled("claude", "work"));
551 cfg.clear_account_enabled("claude", "work");
552 assert_eq!(cfg.account_enabled("claude", "work"), None);
553 assert!(cfg.account_is_enabled("claude", "work"));
554 }
555}