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