Skip to main content

usage_monitor_cli/
config.rs

1//! App configuration: per-provider settings persisted as TOML.
2//!
3//! Each provider can hold one or more named *accounts*. An account carries its
4//! own credentials (token, api_key, credentials_path, …) plus an optional label
5//! and enable toggle, so the same provider can be monitored for several logins.
6//!
7//! When a provider has no configured accounts it still works: the registry uses
8//! a single implicit `default` account that relies on credential auto-detection
9//! (e.g. `~/.claude/.credentials.json`).
10
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13
14use serde::{Deserialize, Serialize};
15
16use crate::error::SpendPanelError;
17
18/// Name of the implicit/primary account used by the convenience commands.
19pub const DEFAULT_ACCOUNT: &str = "default";
20
21/// Per-account settings: credentials plus presentation metadata.
22#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
23pub struct AccountSettings {
24    /// Explicit toggle. `None` means "auto": follows the provider/credential state.
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub enabled: Option<bool>,
27    /// Human-friendly label shown in output (defaults to the account name).
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub label: Option<String>,
30    /// Auth token/cookie for providers with manual authentication.
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub token: Option<String>,
33    /// Workspace IDs (providers that support multiple workspaces, e.g. opencode-go).
34    #[serde(default, skip_serializing_if = "Vec::is_empty")]
35    pub workspaces: Vec<String>,
36    /// Other provider-specific keys (api_key, credentials_path, ...), stored
37    /// flat in the account's table.
38    #[serde(default, flatten)]
39    pub config: HashMap<String, String>,
40}
41
42impl AccountSettings {
43    /// True when the account holds no settings at all (safe to drop).
44    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/// Per-provider settings: a provider-level toggle plus its named accounts.
54#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
55pub struct ProviderSettings {
56    /// Explicit provider-level toggle. `None` means "auto": enabled when
57    /// credentials are detected or accounts are configured.
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub enabled: Option<bool>,
60    /// Named accounts for this provider.
61    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
62    pub accounts: HashMap<String, AccountSettings>,
63}
64
65impl ProviderSettings {
66    /// True when the provider holds no settings at all (safe to drop).
67    pub fn is_empty(&self) -> bool {
68        self.enabled.is_none() && self.accounts.is_empty()
69    }
70}
71
72/// App configuration, persisted at `~/.config/usage-monitor/config.toml`.
73#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
74pub struct AppConfig {
75    #[serde(default)]
76    pub providers: HashMap<String, ProviderSettings>,
77}
78
79/// Resolved enablement state of a provider.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum ProviderState {
82    /// Explicitly enabled in the config file.
83    Enabled,
84    /// Explicitly disabled in the config file.
85    Disabled,
86    /// No explicit setting; enabled because credentials/accounts were detected.
87    AutoEnabled,
88    /// No explicit setting; disabled because nothing was detected.
89    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    /// Default config path: `$XDG_CONFIG_HOME/usage-monitor/config.toml`
100    /// or `~/.config/usage-monitor/config.toml`.
101    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    /// Loads the config from a path. A missing file yields the default config.
111    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    /// Loads the config from the default path (missing file → default config).
128    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    /// Saves the config as TOML, creating parent directories if needed.
136    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    // -----------------------------------------------------------------------
148    // Provider-level toggle
149    // -----------------------------------------------------------------------
150
151    /// Explicit provider-level toggle, if any.
152    pub fn provider_enabled(&self, id: &str) -> Option<bool> {
153        self.providers.get(id).and_then(|p| p.enabled)
154    }
155
156    /// Sets the explicit provider-level toggle.
157    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    /// Clears the explicit provider toggle, returning it to auto-detection.
162    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    /// Resolves the state of a provider: explicit setting wins, otherwise falls
170    /// back to credential detection or the presence of configured accounts.
171    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    // -----------------------------------------------------------------------
181    // Accounts
182    // -----------------------------------------------------------------------
183
184    /// True when the provider has at least one configured account.
185    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    /// Sorted account names configured for a provider (empty when none).
192    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    /// An account's settings, if configured.
203    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    /// Creates an account (no-op if it already exists), returning whether it was
208    /// newly created.
209    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    /// Removes an account entirely. Returns whether it existed.
220    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    /// Sets the account label.
232    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    /// Account label, if set.
237    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    /// Sets a key in an account's table. The `token` key maps to the typed
242    /// field; everything else is stored flat.
243    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    /// Removes a key from an account's table, cleaning up empty entries.
253    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    /// An account's flat config keys (excluding typed fields), if any.
267    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    /// An account's auth token, if set.
272    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    /// An account's workspace list (empty when unset).
277    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    /// Replaces an account's workspace list, cleaning up empty entries.
284    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    /// Explicit per-account toggle, if any.
290    pub fn account_enabled(&self, id: &str, account: &str) -> Option<bool> {
291        self.account(id, account).and_then(|a| a.enabled)
292    }
293
294    /// Sets the explicit per-account toggle.
295    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    /// Clears the explicit per-account toggle, cleaning up empty entries.
300    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    /// True when the account is enabled (explicit toggle wins, else enabled).
310    pub fn account_is_enabled(&self, id: &str, account: &str) -> bool {
311        self.account_enabled(id, account).unwrap_or(true)
312    }
313
314    // -----------------------------------------------------------------------
315    // Internal helpers
316    // -----------------------------------------------------------------------
317
318    /// Gets a mutable account entry, creating provider and account as needed.
319    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    /// Drops an account if it became empty, then prunes the provider.
329    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    /// Drops a provider entry when it holds no settings.
342    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        // Configured accounts auto-enable a provider without an explicit toggle.
463        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        // `token` maps to the typed field, not the flat map.
471        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        // Empty account and provider are removed entirely.
484        assert!(cfg.account("opencode-go", DEFAULT_ACCOUNT).is_none());
485        assert!(!cfg.providers.contains_key("opencode-go"));
486
487        // Other keys land in the flat map.
488        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        // Provider stays because it still has accounts.
517        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)); // already exists
534        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}