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    /// Legacy workspace list, kept so old config files still load. It is no
34    /// longer read by any provider (opencode-go workspace pinning was removed
35    /// when the provider moved to the official Zen usage endpoint).
36    #[serde(default, skip_serializing_if = "Vec::is_empty")]
37    pub workspaces: Vec<String>,
38    /// Other provider-specific keys (api_key, credentials_path, ...), stored
39    /// flat in the account's table.
40    #[serde(default, flatten)]
41    pub config: HashMap<String, String>,
42}
43
44impl AccountSettings {
45    /// True when the account holds no settings at all (safe to drop).
46    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/// Per-provider settings: a provider-level toggle plus its named accounts.
56#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
57pub struct ProviderSettings {
58    /// Explicit provider-level toggle. `None` means "auto": enabled when
59    /// credentials are detected or accounts are configured.
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub enabled: Option<bool>,
62    /// Named accounts for this provider.
63    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
64    pub accounts: HashMap<String, AccountSettings>,
65}
66
67impl ProviderSettings {
68    /// True when the provider holds no settings at all (safe to drop).
69    pub fn is_empty(&self) -> bool {
70        self.enabled.is_none() && self.accounts.is_empty()
71    }
72}
73
74/// App configuration, persisted at `~/.config/usage-monitor/config.toml`.
75#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
76pub struct AppConfig {
77    #[serde(default)]
78    pub providers: HashMap<String, ProviderSettings>,
79}
80
81/// Resolved enablement state of a provider.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum ProviderState {
84    /// Explicitly enabled in the config file.
85    Enabled,
86    /// Explicitly disabled in the config file.
87    Disabled,
88    /// No explicit setting; enabled because credentials/accounts were detected.
89    AutoEnabled,
90    /// No explicit setting; disabled because nothing was detected.
91    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    /// Default config path: `$XDG_CONFIG_HOME/usage-monitor/config.toml`
102    /// or `~/.config/usage-monitor/config.toml`.
103    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    /// Loads the config from a path. A missing file yields the default config.
113    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    /// Loads the config from the default path (missing file → default config).
130    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    /// Saves the config as TOML, creating parent directories if needed.
138    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    // -----------------------------------------------------------------------
150    // Provider-level toggle
151    // -----------------------------------------------------------------------
152
153    /// Explicit provider-level toggle, if any.
154    pub fn provider_enabled(&self, id: &str) -> Option<bool> {
155        self.providers.get(id).and_then(|p| p.enabled)
156    }
157
158    /// Sets the explicit provider-level toggle.
159    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    /// Clears the explicit provider toggle, returning it to auto-detection.
164    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    /// Resolves the state of a provider: explicit setting wins, otherwise falls
172    /// back to credential detection or the presence of configured accounts.
173    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    // -----------------------------------------------------------------------
183    // Accounts
184    // -----------------------------------------------------------------------
185
186    /// True when the provider has at least one configured account.
187    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    /// Sorted account names configured for a provider (empty when none).
194    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    /// An account's settings, if configured.
205    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    /// Creates an account (no-op if it already exists), returning whether it was
210    /// newly created.
211    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    /// Removes an account entirely. Returns whether it existed.
222    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    /// Sets the account label.
234    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    /// Account label, if set.
239    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    /// Sets a key in an account's table. The `token` key maps to the typed
244    /// field; everything else is stored flat.
245    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    /// Removes a key from an account's table, cleaning up empty entries.
255    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    /// An account's flat config keys (excluding typed fields), if any.
269    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    /// An account's auth token, if set.
274    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    /// An account's workspace list (empty when unset).
279    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    /// Replaces an account's workspace list, cleaning up empty entries.
286    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    /// Explicit per-account toggle, if any.
292    pub fn account_enabled(&self, id: &str, account: &str) -> Option<bool> {
293        self.account(id, account).and_then(|a| a.enabled)
294    }
295
296    /// Sets the explicit per-account toggle.
297    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    /// Clears the explicit per-account toggle, cleaning up empty entries.
302    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    /// True when the account is enabled (explicit toggle wins, else enabled).
312    pub fn account_is_enabled(&self, id: &str, account: &str) -> bool {
313        self.account_enabled(id, account).unwrap_or(true)
314    }
315
316    // -----------------------------------------------------------------------
317    // Internal helpers
318    // -----------------------------------------------------------------------
319
320    /// Gets a mutable account entry, creating provider and account as needed.
321    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    /// Drops an account if it became empty, then prunes the provider.
331    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    /// Drops a provider entry when it holds no settings.
344    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        // Configured accounts auto-enable a provider without an explicit toggle.
465        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        // `token` maps to the typed field, not the flat map.
473        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        // Empty account and provider are removed entirely.
486        assert!(cfg.account("opencode-go", DEFAULT_ACCOUNT).is_none());
487        assert!(!cfg.providers.contains_key("opencode-go"));
488
489        // Other keys land in the flat map.
490        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        // Provider stays because it still has accounts.
519        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)); // already exists
536        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}