Skip to main content

ai_usagebar/
config.rs

1//! Config file at `~/.config/ai-usagebar/config.toml`.
2//!
3//! Layout:
4//! ```toml
5//! [anthropic]  enabled = true
6//! [openai]     enabled = true   # Codex OAuth from ~/.codex/auth.json
7//! [copilot]    enabled = false  # GitHub CLI OAuth, or an explicit env override
8//! [zai]        enabled = true
9//! [openrouter] enabled = true
10//! [deepseek]   enabled = false
11//! [kimi]       enabled = false
12//! [grokbot]    enabled = false  # Grok Bot desktop app's own session
13//! [[custom]]   id = "mytool"   # user-defined HTTP provider, static token
14//! ```
15//!
16//! Every field is optional with sensible defaults — missing config file is
17//! treated as "use defaults". API keys are read from env vars (the relevant
18//! `*_api_key_env` field lets the user override which env var name).
19
20use std::collections::{BTreeMap, HashSet};
21use std::path::{Path, PathBuf};
22
23#[cfg(unix)]
24use std::os::unix::fs::{MetadataExt, PermissionsExt};
25
26use serde::{Deserialize, Serialize};
27
28use crate::anthropic::creds::CredsTarget;
29use crate::cache::Cache;
30use crate::error::{AppError, Result};
31use crate::vendor::VendorId;
32
33/// A misspelled section name is silently ignored without this: `[openrouer]`
34/// leaves OpenRouter on its defaults and the user sees the wrong vendor set
35/// with no diagnostic. Denying unknown keys is deliberately applied at the
36/// *section* level only — the set of sections is small and stable, whereas
37/// denying unknown keys inside every section would hard-fail configs that
38/// carry a field from a future or removed version.
39#[derive(Debug, Clone, Default, Deserialize, Serialize)]
40#[serde(default, deny_unknown_fields)]
41pub struct Config {
42    pub ui: UiConfig,
43    pub tray: TrayConfig,
44    pub context: ContextConfig,
45    pub anthropic: AnthropicConfig,
46    pub anthropic_api: AnthropicApiConfig,
47    pub openai: OpenAiConfig,
48    pub copilot: CopilotConfig,
49    pub zai: ZaiConfig,
50    pub openrouter: OpenRouterConfig,
51    pub deepseek: DeepseekConfig,
52    pub kimi: KimiConfig,
53    pub kilo: KiloConfig,
54    pub novita: NovitaConfig,
55    pub moonshot: MoonshotConfig,
56    pub grok: GrokConfig,
57    pub supergrok: SuperGrokConfig,
58    pub grokbot: GrokbotConfig,
59    pub antigravity: AntigravityConfig,
60    pub cursor: CursorConfig,
61    pub minimax: MinimaxConfig,
62    pub kiro: KiroConfig,
63    pub nous: NousConfig,
64    #[serde(rename = "opencode-go")]
65    pub opencode_go: OpenCodeGoConfig,
66    pub commandcode: CommandCodeConfig,
67    pub ollama: OllamaConfig,
68    /// User-defined providers, one `[[custom]]` table each.
69    pub custom: Vec<CustomProviderConfig>,
70}
71
72/// UI / dispatch preferences. Currently just `primary` — which vendor the
73/// widget shows when `--vendor` is omitted, and which TUI tab is selected
74/// at startup.
75#[derive(Debug, Clone, Default, Deserialize, Serialize)]
76#[serde(default)]
77pub struct UiConfig {
78    /// `None` → fall back to anthropic for backward compatibility.
79    pub primary: Option<VendorId>,
80    /// Which vendors the Overview shows (the TUI's first tab and the macOS
81    /// menu-bar's top section), in this order. `None` → every enabled vendor,
82    /// in the canonical order.
83    pub overview_vendors: Option<Vec<VendorId>>,
84    /// Layout style for vendor navigation in the TUI: sidebar | navbar | none.
85    pub vendor_box: Option<VendorBoxStyle>,
86}
87
88impl UiConfig {
89    pub fn vendor_box(&self) -> VendorBoxStyle {
90        self.vendor_box.unwrap_or_default()
91    }
92}
93
94/// Windows tray popover preferences the host process needs before the
95/// WebView is up: the global shortcut it registers, how often it polls and
96/// how it treats new releases. Screen-only preferences (theme, density, time
97/// format) live in the popover's own storage instead.
98#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
99#[serde(default)]
100pub struct TrayConfig {
101    /// Global shortcut that toggles the popover, in the canonical
102    /// "Ctrl+Shift+U" spelling. `None` → no shortcut registered.
103    pub shortcut: Option<String>,
104    /// How often the tray re-reads every provider, in minutes: 1, 5 or 10.
105    /// The footer's Refresh is always immediate. `None` → 5.
106    pub refresh_minutes: Option<u64>,
107    /// What the tray does when a newer release is published.
108    pub updates: Option<UpdateMode>,
109}
110
111/// Poll intervals the tray offers, in minutes. The provider cache TTL is
112/// 60 s regardless; this only decides how often the tray asks.
113pub const TRAY_REFRESH_MINUTES: [u64; 3] = [1, 5, 10];
114const DEFAULT_TRAY_REFRESH_MINUTES: u64 = 5;
115
116impl TrayConfig {
117    pub fn refresh_minutes(&self) -> u64 {
118        self.refresh_minutes.unwrap_or(DEFAULT_TRAY_REFRESH_MINUTES)
119    }
120
121    pub fn updates(&self) -> UpdateMode {
122        self.updates.unwrap_or_default()
123    }
124}
125
126/// How the tray handles a newer release: install it unattended, show a
127/// banner with an Install button, or never check in the background.
128#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
129#[serde(rename_all = "lowercase")]
130pub enum UpdateMode {
131    Auto,
132    #[default]
133    Notify,
134    Off,
135}
136
137impl UpdateMode {
138    pub fn as_str(self) -> &'static str {
139        match self {
140            Self::Auto => "auto",
141            Self::Notify => "notify",
142            Self::Off => "off",
143        }
144    }
145
146    pub fn parse(text: &str) -> Option<Self> {
147        match text.trim().to_ascii_lowercase().as_str() {
148            "auto" => Some(Self::Auto),
149            "notify" => Some(Self::Notify),
150            "off" => Some(Self::Off),
151            _ => None,
152        }
153    }
154}
155
156/// Presentation style of the TUI vendor navigation box.
157#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
158#[serde(rename_all = "lowercase")]
159pub enum VendorBoxStyle {
160    /// Vertical sidebar box on wide terminals; falls back to top navbar on narrow terminals.
161    #[default]
162    Sidebar,
163    /// Horizontal navbar strip above the dashboard detail panel.
164    Navbar,
165    /// Completely hide vendor navigation (dashboards expand to fill full width).
166    None,
167}
168
169/// Where the context view docks in the dashboard body. `v` cycles it while the
170/// overlay is open; the config value is what it opens with.
171#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
172#[serde(rename_all = "lowercase")]
173pub enum ContextLayout {
174    /// Takes the whole body, the way a vendor panel does.
175    #[default]
176    Full,
177    /// Beside the dashboard.
178    Split,
179    /// Below the dashboard.
180    Bottom,
181}
182
183impl ContextLayout {
184    pub fn next(self) -> Self {
185        match self {
186            ContextLayout::Full => ContextLayout::Split,
187            ContextLayout::Split => ContextLayout::Bottom,
188            ContextLayout::Bottom => ContextLayout::Full,
189        }
190    }
191
192    pub fn label(self) -> &'static str {
193        match self {
194            ContextLayout::Full => "full",
195            ContextLayout::Split => "split",
196            ContextLayout::Bottom => "bottom",
197        }
198    }
199}
200
201/// Optional local Claude Code context-window monitor. This is deliberately
202/// separate from vendors: sessions are discovered from local transcripts and
203/// change while the TUI is running, whereas vendor tabs are config-declared
204/// account identities.
205#[derive(Debug, Clone, Default, Deserialize, Serialize)]
206#[serde(default)]
207pub struct ContextConfig {
208    /// Keep the filesystem scanner completely dormant unless explicitly
209    /// enabled. The `c` key and its footer hint are hidden while disabled.
210    pub enabled: bool,
211    /// Override Claude Code's normal `~/.claude/projects` transcript root.
212    pub projects_path: Option<PathBuf>,
213    /// Optional fallback denominator. When absent, sessions without an exact
214    /// model override show their input-token count without inventing a %.
215    pub context_window_tokens: Option<u64>,
216    /// Exact Claude model id -> context-window size. This takes precedence
217    /// over `context_window_tokens`, which keeps mixed 200K/1M histories safe.
218    pub model_context_window_tokens: BTreeMap<String, u64>,
219    /// Where the view opens: full | split | bottom.
220    pub layout: ContextLayout,
221}
222
223impl ContextConfig {
224    pub fn window_tokens_for(&self, model: Option<&str>) -> Option<u64> {
225        model
226            .and_then(|model| self.model_context_window_tokens.get(model).copied())
227            .filter(|tokens| *tokens > 0)
228            .or_else(|| self.context_window_tokens.filter(|tokens| *tokens > 0))
229    }
230}
231
232#[derive(Debug, Clone, Deserialize, Serialize)]
233#[serde(default)]
234pub struct AnthropicConfig {
235    pub enabled: bool,
236    /// Override the credentials file path (defaults to `~/.claude/.credentials.json`).
237    /// This is the *default* account; extra subscriptions go in `accounts`.
238    pub credentials_path: Option<PathBuf>,
239    /// Extra Anthropic accounts beyond the default, each selected on the CLI
240    /// with `--account <label>` (issue #14). Empty by default, so existing
241    /// single-account configs are byte-for-byte unchanged.
242    pub accounts: Vec<AnthropicAccount>,
243    /// Directory to auto-discover extra accounts from, in Claude Code's own
244    /// `CLAUDE_CONFIG_DIR` layout: each immediate subdirectory becomes an
245    /// account labeled by the subdirectory name. The credentials may live in
246    /// that directory's `.credentials.json` or in the macOS Keychain, so
247    /// discovery intentionally does not probe for the credentials file.
248    /// Merged with `accounts` (explicit wins on a label clash); each is
249    /// refreshed independently.
250    pub accounts_dir: Option<PathBuf>,
251    /// Whether the default (unnamed) Claude account gets its own tab. Defaults
252    /// to `true` for back-compat. Set `false` when every account is managed
253    /// explicitly (via `accounts`/`accounts_dir`) so the ambient
254    /// Keychain/`~/.claude` login doesn't add a redundant "Claude" tab. Ignored
255    /// when there are no named accounts, so Anthropic never loses its only tab.
256    pub show_default_account: bool,
257    /// Where the Claude **Desktop app**'s saved account profiles live. Defaults
258    /// to `~/.claude-acc/profiles`, the store claude-acc
259    /// (<https://github.com/ohmaseclaro/claude-acc>) creates — `account switch`
260    /// reads and writes that layout so the two tools stay interchangeable.
261    /// Unrelated to `accounts_dir`, which is the `claude` CLI's own accounts.
262    pub desktop_profiles_dir: Option<PathBuf>,
263}
264
265impl Default for AnthropicConfig {
266    fn default() -> Self {
267        Self {
268            enabled: true,
269            credentials_path: None,
270            accounts: Vec::new(),
271            accounts_dir: None,
272            show_default_account: true,
273            desktop_profiles_dir: None,
274        }
275    }
276}
277
278/// One extra Anthropic account beyond the default (issue #14). The default
279/// account stays the singular `[anthropic] credentials_path`; each entry here
280/// is an additional subscription selected on the CLI with `--account <label>`.
281///
282/// ```toml
283/// [[anthropic.accounts]]
284/// label = "work"
285/// credentials_path = "~/.config/ai-usagebar/accounts/work/.credentials.json"
286/// ```
287#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
288pub struct AnthropicAccount {
289    /// Stable name used on the CLI (`--account <label>`) and as the cache
290    /// subdir (`~/.cache/ai-usagebar/anthropic/<label>`).
291    pub label: String,
292    /// OAuth credentials file for this account (same JSON shape Claude Code
293    /// writes). Token refreshes are written back here, so each account keeps
294    /// itself alive independently.
295    pub credentials_path: PathBuf,
296}
297
298impl AnthropicAccount {
299    /// The `CLAUDE_CONFIG_DIR` this account occupies — the credential file's
300    /// own directory. Claude Code hashes exactly this path for the account's
301    /// Keychain item, so it is also the account's identity for
302    /// [`crate::anthropic::keychain`].
303    pub fn config_dir(&self) -> PathBuf {
304        self.credentials_path
305            .parent()
306            .map_or_else(|| self.credentials_path.clone(), Path::to_path_buf)
307    }
308}
309
310impl AnthropicConfig {
311    /// Every extra account: the explicit `[[anthropic.accounts]]` entries plus
312    /// any auto-discovered under [`accounts_dir`](AnthropicConfig::accounts_dir).
313    /// Explicit entries take precedence on a label clash. This is what tabs and
314    /// `--account` enumerate, so a discovered account behaves exactly like a
315    /// hand-written one (own cache subdir, independent refresh).
316    pub fn all_accounts(&self) -> Vec<AnthropicAccount> {
317        let mut out = self.accounts.clone();
318        if let Some(dir) = &self.accounts_dir {
319            for acct in discover_accounts(dir) {
320                if !out.iter().any(|a| a.label == acct.label) {
321                    out.push(acct);
322                }
323            }
324        }
325        out
326    }
327
328    /// Find an extra account by label (explicit or discovered), or error listing
329    /// the known labels so a typo fails loudly instead of silently hitting the
330    /// default. Returns an owned account because discovered entries are
331    /// synthesized, not stored.
332    pub fn account(&self, label: &str) -> Result<AnthropicAccount> {
333        validate_account_label(label)?;
334        let all = self.all_accounts();
335        all.iter().find(|a| a.label == label).cloned().ok_or_else(|| {
336            let known: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
337            AppError::Credentials(format!(
338                "anthropic account {label:?} not found in [[anthropic.accounts]] or accounts_dir; \
339                 known labels: {known:?}"
340            ))
341        })
342    }
343
344    /// Resolve a named account to the credentials target + isolated cache it
345    /// fetches through: [`CredsTarget::Named`], which on macOS prefers the
346    /// Keychain item scoped to the file's own directory (that is where
347    /// `CLAUDE_CONFIG_DIR=<dir> claude` actually writes) and falls back to
348    /// the file elsewhere — never a *different* account's item, since the
349    /// hash is per-directory, so issue #15's cross-account concern doesn't
350    /// apply. Plus an `anthropic/<label>` cache subdir. Shared by the widget
351    /// (`--account`) and the TUI's per-account tab (#14, #17) so both resolve
352    /// accounts identically; the widget layers its `--cache-dir` override on
353    /// top of the cache returned here.
354    pub fn account_target(&self, label: &str) -> Result<(CredsTarget, Cache)> {
355        let active = crate::anthropic::cli_account::home_claude_json()
356            .ok()
357            .and_then(|path| {
358                crate::anthropic::cli_account::resolve_active_label(&path, &self.all_accounts())
359            });
360        self.account_target_with(label, active.as_deref())
361    }
362
363    /// The pure half of [`account_target`](AnthropicConfig::account_target),
364    /// with "which account the `claude` CLI is signed into" injected — the same
365    /// shape as `Cli::resolve_vendor_with`. Probes the account's own credential
366    /// file with [`Path::exists`]; [`account_target_probing`] takes that probe
367    /// as an argument.
368    ///
369    /// [`account_target_probing`]: AnthropicConfig::account_target_probing
370    pub fn account_target_with(
371        &self,
372        label: &str,
373        cli_active: Option<&str>,
374    ) -> Result<(CredsTarget, Cache)> {
375        self.account_target_probing(label, cli_active, |path| path.exists())
376    }
377
378    /// The pure core: `exists` answers whether the account's own credential
379    /// file is there.
380    ///
381    /// When `label` *is* the live CLI login, `account switch` has moved its
382    /// credential into the default slot **and removed it from the named
383    /// one** — so reading the default keeps exactly one live lineage, and a
384    /// refresh here cannot invalidate the credential `claude` is using (or the
385    /// other way round). That only holds while the named slot really is empty,
386    /// which is why it is probed rather than assumed: a `CLAUDE_CONFIG_DIR`
387    /// layout keeps a live credential in every directory, and two of those
388    /// directories can hold the *same* account, which is what
389    /// `resolve_active_label` matches on. Believing the marker there sent every
390    /// fetch for that label to `~/.claude/.credentials.json` — a slot the user
391    /// never logs into, whose refresh token had expired, so a working account
392    /// reported "run `claude` to re-auth" while its own file sat live and
393    /// unread next to it.
394    ///
395    /// The cache directory is unchanged either way, so the tab keeps its
396    /// identity and its cached usage across a switch.
397    pub fn account_target_probing(
398        &self,
399        label: &str,
400        cli_active: Option<&str>,
401        exists: impl Fn(&Path) -> bool,
402    ) -> Result<(CredsTarget, Cache)> {
403        let account = self.account(label)?;
404        let cache = Cache::for_vendor_account("anthropic", label)?;
405        if cli_active == Some(label) && !exists(&account.credentials_path) {
406            return Ok((
407                CredsTarget::Default(crate::anthropic::creds::default_path()?),
408                cache,
409            ));
410        }
411        Ok((
412            CredsTarget::Named {
413                config_dir: account.config_dir(),
414                path: account.credentials_path,
415            },
416            cache,
417        ))
418    }
419}
420
421/// The label doubles as a cache subdirectory name
422/// (`~/.cache/ai-usagebar/anthropic/<label>/`), which nests inside the default
423/// account's cache dir — so path separators, control characters, or reserved
424/// cache sidecar names would escape, spoof terminal output, or collide with the
425/// cache layout (`usage.json`, `.stale`, …).
426pub fn validate_account_label(label: &str) -> Result<()> {
427    validate_account_label_for("anthropic", label)
428}
429
430fn validate_account_label_for(vendor: &str, label: &str) -> Result<()> {
431    const RESERVED: [&str; 4] = ["usage.json", ".stale", ".last_error", ".fetch.lock"];
432    let bad = label.is_empty()
433        || label == "."
434        || label == ".."
435        || label.contains(['/', '\\'])
436        || label.contains(':')
437        || label.chars().any(char::is_control)
438        || RESERVED.contains(&label);
439    if bad {
440        return Err(AppError::Credentials(format!(
441            "invalid {vendor} account label {label:?}: must be a non-empty name \
442             without path separators, drive prefixes, control characters, or reserved cache names"
443        )));
444    }
445    Ok(())
446}
447
448/// Discover accounts under `accounts_dir` in the `CLAUDE_CONFIG_DIR` layout:
449/// each immediate subdirectory becomes an account labeled by the subdirectory
450/// name. Best-effort: an unreadable directory or unusable label is skipped
451/// silently rather than failing the whole config — discovery is convenience,
452/// while an explicit `[[anthropic.accounts]]` entry stays authoritative. The
453/// fetch path resolves credentials from either `.credentials.json` or the macOS
454/// Keychain. Sorted by label so the tab order is stable across runs.
455fn discover_accounts(accounts_dir: &std::path::Path) -> Vec<AnthropicAccount> {
456    let Ok(entries) = std::fs::read_dir(accounts_dir) else {
457        return Vec::new();
458    };
459    let mut found: Vec<AnthropicAccount> = entries
460        .flatten()
461        .filter_map(|entry| {
462            let path = entry.path();
463            if !path.is_dir() {
464                return None;
465            }
466            let label = path.file_name()?.to_str()?.to_string();
467            validate_account_label(&label).ok()?;
468            Some(AnthropicAccount {
469                label,
470                credentials_path: path.join(".credentials.json"),
471            })
472        })
473        .collect();
474    found.sort_by(|a, b| a.label.cmp(&b.label));
475    found
476}
477
478/// Render a path with `$HOME` collapsed back to `~`, matching the style the docs
479/// and existing `[[anthropic.accounts]]` entries use. Pure so it's testable;
480/// paths outside home are returned verbatim.
481pub fn tildify(path: &Path, home: &Path) -> String {
482    path.strip_prefix(home)
483        .map(|rest| {
484            let rendered = rest.display().to_string();
485            // Config paths use the same portable `~/...` spelling on every
486            // platform. A Windows `~\...` would not be expanded by the loader.
487            #[cfg(windows)]
488            let rendered = rendered.replace('\\', "/");
489            format!("~/{rendered}")
490        })
491        .unwrap_or_else(|_| path.display().to_string())
492}
493
494/// Where a newly-registered account's credentials file lives by default: next
495/// to `config.toml`, under `accounts/<label>/.credentials.json`. Returns the
496/// absolute path (for `mkdir`) — tilde-render it with [`tildify`] for display
497/// and for the value written into config.
498pub fn default_account_credentials_path(config_path: &Path, label: &str) -> PathBuf {
499    let base = config_path.parent().unwrap_or_else(|| Path::new("."));
500    base.join("accounts").join(label).join(".credentials.json")
501}
502
503/// Append a `[[anthropic.accounts]]` entry to a parsed config document, in
504/// place. Pure over a `toml_edit` document so the validation, duplicate check,
505/// and formatting are testable without disk. Preserves the rest of the file
506/// (comments, key order, other sections) — only the new array-of-tables entry
507/// is added. Errors on an invalid label or a label that already exists.
508pub fn add_anthropic_account_to_doc(
509    doc: &mut toml_edit::DocumentMut,
510    label: &str,
511    credentials_path: &str,
512) -> Result<()> {
513    use toml_edit::{Item, Table, value};
514
515    validate_account_label(label)?;
516
517    let anthropic = doc
518        .entry("anthropic")
519        .or_insert_with(|| Item::Table(Table::new()));
520    let anthropic = anthropic
521        .as_table_mut()
522        .ok_or_else(|| AppError::Other("[anthropic] in config.toml is not a table".into()))?;
523
524    let accounts = anthropic
525        .entry("accounts")
526        .or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()));
527    let accounts = accounts.as_array_of_tables_mut().ok_or_else(|| {
528        AppError::Other("[[anthropic.accounts]] in config.toml is not an array of tables".into())
529    })?;
530
531    let exists = accounts
532        .iter()
533        .any(|t| t.get("label").and_then(Item::as_str) == Some(label));
534    if exists {
535        return Err(AppError::Credentials(format!(
536            "anthropic account {label:?} already exists in config.toml"
537        )));
538    }
539
540    let mut table = Table::new();
541    table["label"] = value(label);
542    table["credentials_path"] = value(credentials_path);
543    accounts.push(table);
544    Ok(())
545}
546
547/// Set or update a boolean field in a TOML section, preserving comments and
548/// formatting of unaffected nodes. Shared by the Settings overlay and
549/// [`enable_vendors_in`] so both writers shape `enabled = true` identically.
550pub(crate) fn set_bool(
551    doc: &mut toml_edit::DocumentMut,
552    section: &str,
553    key: &str,
554    new_value: bool,
555) -> Result<()> {
556    let table = doc
557        .entry(section)
558        .or_insert_with(toml_edit::table)
559        .as_table_mut()
560        .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
561
562    if let Some(item) = table.get_mut(key)
563        && let Some(v) = item.as_value_mut()
564    {
565        // Keep a trailing `# comment` on the line being rewritten: the value
566        // is the only thing that changed, and the note beside it is the
567        // user's.
568        let suffix = v.decor().suffix().cloned();
569        *v = toml_edit::Value::from(new_value);
570        v.decor_mut().set_prefix(" ");
571        if let Some(suffix) = suffix {
572            v.decor_mut().set_suffix(suffix);
573        }
574        return Ok(());
575    }
576    table.insert(key, toml_edit::value(new_value));
577    Ok(())
578}
579
580/// Set, replace or remove a scalar field in a TOML section, preserving
581/// comments and formatting of unaffected nodes. `None` removes the key so a
582/// cleared preference does not linger as an empty string. The value keeps
583/// its own TOML type on disk — an integer preference such as
584/// `refresh_minutes` must not be quoted, or `Config::load_from` rejects it.
585pub(crate) fn set_value(
586    doc: &mut toml_edit::DocumentMut,
587    section: &str,
588    key: &str,
589    new_value: Option<toml_edit::Value>,
590) -> Result<()> {
591    let table = doc
592        .entry(section)
593        .or_insert_with(toml_edit::table)
594        .as_table_mut()
595        .ok_or_else(|| AppError::Other(format!("config.toml: [{section}] is not a table")))?;
596
597    let Some(mut new_value) = new_value else {
598        table.remove(key);
599        return Ok(());
600    };
601    if let Some(item) = table.get_mut(key)
602        && let Some(v) = item.as_value_mut()
603    {
604        let suffix = v.decor().suffix().cloned();
605        new_value.decor_mut().set_prefix(" ");
606        if let Some(suffix) = suffix {
607            new_value.decor_mut().set_suffix(suffix);
608        }
609        *v = new_value;
610        return Ok(());
611    }
612    table.insert(key, toml_edit::Item::Value(new_value));
613    Ok(())
614}
615
616/// Write one `[tray]` preference into the config at `path`, creating the
617/// file when it doesn't exist and leaving every other line as it was.
618/// `None` removes the key. The value keeps the TOML type it is given
619/// (`"notify"` stays a string, `5` stays an integer). The tray host is the
620/// only writer.
621pub fn set_tray_value(path: &Path, key: &str, value: Option<toml_edit::Value>) -> Result<()> {
622    let mut doc = read_config_document(path)?;
623    let before = doc.to_string();
624    set_value(&mut doc, "tray", key, value)?;
625    if doc.to_string() == before {
626        return Ok(());
627    }
628    write_config_document(path, &doc)
629}
630
631/// Read `path` into a `toml_edit` document with comments intact. A missing
632/// file is an empty document, so a writer can create the config from nothing;
633/// any other I/O failure or a parse error is reported rather than clobbered.
634pub(crate) fn read_config_document(path: &Path) -> Result<toml_edit::DocumentMut> {
635    let original = match std::fs::read_to_string(path) {
636        Ok(contents) => contents,
637        Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
638        Err(error) => return Err(AppError::io_at(path, error)),
639    };
640    if original.trim().is_empty() {
641        return Ok(toml_edit::DocumentMut::new());
642    }
643    original.parse().map_err(|e: toml_edit::TomlError| {
644        AppError::Other(format!("config.toml not parseable: {e}"))
645    })
646}
647
648/// Persist an edited config document: parent dir created, atomic
649/// tempfile-and-rename write, and `chmod 600` on Unix because the file may
650/// carry inline credentials. The one write path for every config editor.
651pub(crate) fn write_config_document(path: &Path, doc: &toml_edit::DocumentMut) -> Result<()> {
652    let bytes = doc.to_string();
653    crate::cache::atomic_write(path, bytes.as_bytes())?;
654
655    #[cfg(unix)]
656    {
657        if let Ok(meta) = std::fs::metadata(path) {
658            let mut perms = meta.permissions();
659            perms.set_mode(0o600);
660            let _ = std::fs::set_permissions(path, perms);
661        }
662    }
663    Ok(())
664}
665
666/// Flip `enabled = true` for each vendor's section in the config at `path`,
667/// creating the file when it doesn't exist and leaving every other line —
668/// comments, keys, unrelated sections — exactly as it was. Never writes
669/// `false`: `config.toml` stays the user's source of truth and this only ever
670/// widens it. A document that comes out textually unchanged (every vendor
671/// already enabled) is not rewritten, so an idempotent call doesn't touch the
672/// file's mtime or race a concurrent editor.
673pub fn enable_vendors_in(path: &Path, vendors: &[VendorId]) -> Result<Vec<VendorId>> {
674    let mut doc = read_config_document(path)?;
675    let before = doc.to_string();
676    let written: Vec<VendorId> = vendors
677        .iter()
678        .copied()
679        .filter(|vendor| !is_explicitly_disabled(&doc, *vendor))
680        .collect();
681    for vendor in &written {
682        set_bool(&mut doc, vendor.config_section(), "enabled", true)?;
683    }
684    if doc.to_string() == before {
685        return Ok(written);
686    }
687    write_config_document(path, &doc)?;
688    Ok(written)
689}
690
691/// Whether the config *says* `enabled = false` for this vendor, as opposed to
692/// not mentioning it.
693///
694/// This is the durable record of a user having turned a vendor off. `detect`
695/// also keeps a set of vendors it has already considered, but that lives in the
696/// cache directory, which is by convention safe to delete — so it cannot be the
697/// only thing standing between "the user opted out" and re-enabling a provider
698/// (and resuming requests to it) behind their back. The config file is the one
699/// place that outlives a cache wipe, so the explicit `false` is honored here,
700/// at the write, where no caller can route around it.
701fn is_explicitly_disabled(doc: &toml_edit::DocumentMut, vendor: VendorId) -> bool {
702    doc.get(vendor.config_section())
703        .and_then(|section| section.get("enabled"))
704        .and_then(|enabled| enabled.as_bool())
705        == Some(false)
706}
707
708#[derive(Debug, Clone, Deserialize, Serialize)]
709#[serde(default)]
710pub struct OpenAiConfig {
711    pub enabled: bool,
712    /// Override the Codex auth file path (defaults to `~/.codex/auth.json`).
713    pub codex_auth_path: Option<PathBuf>,
714    /// Extra Codex logins, each its own `auth.json`. Same shape as
715    /// [`AnthropicAccount`] and for the same reason: Codex is an OAuth vendor,
716    /// so an account *is* a credential file, and `openai::creds::write_back`
717    /// refreshes into whichever one it read.
718    #[serde(default)]
719    pub accounts: Vec<OpenAiAccount>,
720    /// Reserved, and inert: names the env var an API-key-only path *would*
721    /// read (admin key → `/v1/organization/costs`). Nothing consumes it —
722    /// OpenAI usage comes solely from Codex OAuth. Kept because that path is
723    /// still intended, not for back-compat: `[openai]` doesn't deny unknown
724    /// fields, so an existing `admin_key_env` would load either way. See
725    /// `config.example.toml`, which ships it commented out so nobody sets it
726    /// expecting an effect.
727    pub admin_key_env: String,
728}
729
730/// One extra Codex login.
731///
732/// ```toml
733/// [[openai.accounts]]
734/// label = "work"
735/// codex_auth_path = "~/.config/ai-usagebar/accounts/work-codex/auth.json"
736/// ```
737///
738/// A second login is made with `CODEX_HOME=~/.codex-work codex login`; point
739/// `codex_auth_path` at the `auth.json` it writes.
740#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
741pub struct OpenAiAccount {
742    /// Stable name used on the CLI (`--account <label>`) and as the cache
743    /// subdir (`~/.cache/ai-usagebar/openai/<label>`).
744    pub label: String,
745    /// Codex OAuth file for this account. Refreshed tokens are written back
746    /// here, so each account keeps itself alive independently.
747    pub codex_auth_path: PathBuf,
748}
749
750impl OpenAiConfig {
751    /// The auth file for `label`, or the singular/default one when `label` is
752    /// `None`. An unknown label is an error rather than a silent fall back to
753    /// the default account, which would report the wrong login's usage.
754    pub fn resolve_auth_path(&self, label: Option<&str>) -> Result<PathBuf> {
755        let Some(label) = label else {
756            return match &self.codex_auth_path {
757                Some(path) => Ok(path.clone()),
758                None => crate::openai::creds::default_path(),
759            };
760        };
761        self.accounts
762            .iter()
763            .find(|account| account.label == label)
764            .map(|account| account.codex_auth_path.clone())
765            .ok_or_else(|| {
766                AppError::Credentials(format!(
767                    "no OpenAI account named {label:?}. Add it under \
768                     [[openai.accounts]], or drop --account to use the default login."
769                ))
770            })
771    }
772}
773
774impl Default for OpenAiConfig {
775    fn default() -> Self {
776        Self {
777            enabled: true,
778            codex_auth_path: None,
779            accounts: Vec::new(),
780            admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
781        }
782    }
783}
784
785/// GitHub Copilot quota from the private endpoint used by VS Code. The token
786/// comes from an explicit environment override or the official GitHub CLI;
787/// this app never reads, copies, or writes GitHub credential stores.
788#[derive(Debug, Clone, Default, Deserialize, Serialize)]
789#[serde(default)]
790pub struct CopilotConfig {
791    pub enabled: bool,
792    /// Path to the official GitHub CLI. Unset looks `gh` up on `PATH`, which
793    /// is how `gh` is normally installed; set it to pin the executable.
794    pub gh_binary: Option<PathBuf>,
795}
796
797impl CopilotConfig {
798    pub fn resolve_token(&self) -> Result<String> {
799        self.resolve_token_with(
800            |name| std::env::var_os(name),
801            &crate::copilot::credentials::SystemGhAuthTokenRunner,
802        )
803    }
804
805    fn resolve_token_with(
806        &self,
807        environment: impl Fn(&str) -> Option<std::ffi::OsString>,
808        runner: &impl crate::copilot::credentials::GhAuthTokenRunner,
809    ) -> Result<String> {
810        if let Some(value) = environment("GITHUB_COPILOT_TOKEN") {
811            let token = value.into_string().map_err(|_| {
812                AppError::Credentials(
813                    "GitHub Copilot: GITHUB_COPILOT_TOKEN is not valid UTF-8.".into(),
814                )
815            })?;
816            if !token.is_empty() {
817                return Ok(token);
818            }
819        }
820        crate::copilot::credentials::resolve_with(runner, self.gh_binary.as_deref())
821    }
822}
823
824#[derive(Debug, Clone, Default, Deserialize, Serialize)]
825#[serde(default)]
826pub struct NousConfig {
827    pub enabled: bool,
828}
829
830#[derive(Debug, Clone, Deserialize, Serialize)]
831#[serde(default)]
832pub struct OpenCodeGoConfig {
833    pub enabled: bool,
834    pub api_key_env: String,
835    pub api_key: Option<String>,
836}
837
838/// Command Code reads the OAuth credential from the official CLI or pi, so it
839/// has no API key of its own. `auth_paths` overrides that search list for a
840/// non-standard install. It is enabled by default, like OpenAI/Codex; when no
841/// local credential exists the TUI reports that tab as unavailable instead of
842/// silently hiding the provider.
843#[derive(Debug, Clone, Deserialize, Serialize)]
844#[serde(default)]
845pub struct CommandCodeConfig {
846    pub enabled: bool,
847    pub auth_paths: Option<Vec<PathBuf>>,
848}
849
850impl Default for CommandCodeConfig {
851    fn default() -> Self {
852        Self {
853            enabled: true,
854            auth_paths: None,
855        }
856    }
857}
858
859/// Ollama Cloud (`ollama.com/api/usage`). Disabled by default: the local
860/// `ollama` daemon is the product most users reach for, and it has no quota
861/// route to query. Cloud quota is opt-in, with the key taken from
862/// `OLLAMA_API_KEY` (or `api_key` as a fallback for `chmod 600` configs).
863#[derive(Debug, Clone, Deserialize, Serialize)]
864#[serde(default)]
865pub struct OllamaConfig {
866    pub enabled: bool,
867    pub api_key_env: String,
868    pub api_key: Option<String>,
869    /// Display label for the plan row. The API itself does not report a plan
870    /// name; "pro" is what an Ollama Cloud Pro account shows in the UI.
871    pub plan: String,
872}
873
874impl Default for OllamaConfig {
875    fn default() -> Self {
876        Self {
877            enabled: false,
878            api_key_env: "OLLAMA_API_KEY".to_string(),
879            api_key: None,
880            plan: "pro".to_string(),
881        }
882    }
883}
884
885impl Default for OpenCodeGoConfig {
886    fn default() -> Self {
887        Self {
888            enabled: false,
889            api_key_env: "OPENCODE_GO_API_KEY".to_string(),
890            api_key: None,
891        }
892    }
893}
894
895#[derive(Debug, Clone, Deserialize, Serialize)]
896#[serde(default)]
897pub struct ZaiConfig {
898    pub enabled: bool,
899    /// Env var name to read the key from (env wins over `api_key`).
900    pub api_key_env: String,
901    /// Inline key (fallback when the env var is unset). Chmod 600 your
902    /// config file if you put a real key here.
903    pub api_key: Option<String>,
904    /// Optional plan tier label (lite/pro/max) — display-only.
905    pub plan_tier: Option<String>,
906}
907
908impl Default for ZaiConfig {
909    fn default() -> Self {
910        Self {
911            enabled: true,
912            api_key_env: "ZAI_API_KEY".to_string(),
913            api_key: None,
914            plan_tier: None,
915        }
916    }
917}
918
919#[derive(Debug, Clone, Deserialize, Serialize)]
920#[serde(default)]
921pub struct OpenRouterConfig {
922    pub enabled: bool,
923    /// Extra OpenRouter accounts beyond the default key. Each account gets a
924    /// separate aggregate-view entry and cache directory.
925    pub accounts: Vec<OpenRouterAccount>,
926    /// Whether aggregate views include the default (unnamed) key when named
927    /// accounts exist. Ignored when `accounts` is empty so OpenRouter never
928    /// loses its only tab.
929    pub show_default_account: bool,
930    pub api_key_env: String,
931    pub api_key: Option<String>,
932}
933
934impl Default for OpenRouterConfig {
935    fn default() -> Self {
936        Self {
937            enabled: true,
938            accounts: Vec::new(),
939            show_default_account: true,
940            api_key_env: "OPENROUTER_API_KEY".to_string(),
941            api_key: None,
942        }
943    }
944}
945
946/// One named OpenRouter account. The default account continues to use the
947/// singular `api_key_env` / `api_key` fields under `[openrouter]`.
948#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
949pub struct OpenRouterAccount {
950    /// Stable CLI/report label and account-scoped cache subdirectory.
951    pub label: String,
952    /// Optional environment variable containing this account's key.
953    #[serde(default)]
954    pub api_key_env: Option<String>,
955    /// Inline fallback when the account environment variable is unset.
956    #[serde(default)]
957    pub api_key: Option<String>,
958}
959
960impl OpenRouterConfig {
961    /// Find a named account or fail loudly instead of falling back to the
962    /// default key (which would show the wrong account's usage).
963    pub fn account(&self, label: &str) -> Result<&OpenRouterAccount> {
964        validate_account_label_for("openrouter", label)?;
965        self.accounts
966            .iter()
967            .find(|account| account.label == label)
968            .ok_or_else(|| {
969                let known: Vec<&str> = self
970                    .accounts
971                    .iter()
972                    .map(|account| account.label.as_str())
973                    .collect();
974                AppError::Credentials(format!(
975                    "openrouter account {label:?} not found in [[openrouter.accounts]]; \
976                     known labels: {known:?}"
977                ))
978            })
979    }
980
981    /// Resolve either the backward-compatible default key or one named
982    /// account. Configured values are never included in an error message.
983    pub fn resolve_api_key(&self, label: Option<&str>) -> Result<String> {
984        match label {
985            None => resolve_api_key("OpenRouter", &self.api_key_env, self.api_key.as_deref()),
986            Some(label) => {
987                let account = self.account(label)?;
988                resolve_api_key_in_section(
989                    &format!("OpenRouter account {label:?}"),
990                    "[[openrouter.accounts]]",
991                    account.api_key_env.as_deref().unwrap_or(""),
992                    account.api_key.as_deref(),
993                )
994            }
995        }
996    }
997}
998
999#[derive(Debug, Clone, Deserialize, Serialize)]
1000#[serde(default)]
1001pub struct DeepseekConfig {
1002    pub enabled: bool,
1003    pub api_key_env: String,
1004    pub api_key: Option<String>,
1005}
1006
1007impl Default for DeepseekConfig {
1008    fn default() -> Self {
1009        Self {
1010            enabled: false,
1011            api_key_env: "DEEPSEEK_API_KEY".to_string(),
1012            api_key: None,
1013        }
1014    }
1015}
1016
1017#[derive(Debug, Clone, Deserialize, Serialize)]
1018#[serde(default)]
1019pub struct KimiConfig {
1020    pub enabled: bool,
1021    pub api_key_env: String,
1022    /// Optional: with no key set, the vendor falls back to the Kimi Code CLI's
1023    /// own OAuth login, which is what a subscriber already has locally.
1024    pub api_key: Option<String>,
1025    /// Override for kimi-code's credential file (default
1026    /// `~/.kimi-code/credentials/kimi-code.json`), mirroring `[cursor] db_path`
1027    /// and `[kiro] db_path`. Useful with a relocated `KIMI_CODE_HOME`.
1028    pub credentials_path: Option<PathBuf>,
1029    /// `"auto"` follows kimi-code's own install marker (`~/.kimi-code/region`);
1030    /// `"cn"` pins `api.kimi.com` / `auth.kimi.com`, `"global"` pins
1031    /// `api.kimi.ai` / `auth.kimi.ai`. A token minted by one deployment means
1032    /// nothing to the other, so this picks the instance, not a currency.
1033    pub region: String,
1034}
1035
1036impl Default for KimiConfig {
1037    fn default() -> Self {
1038        Self {
1039            enabled: false,
1040            api_key_env: "KIMI_API_KEY".to_string(),
1041            api_key: None,
1042            credentials_path: None,
1043            region: "auto".to_string(),
1044        }
1045    }
1046}
1047
1048#[derive(Debug, Clone, Deserialize, Serialize)]
1049#[serde(default)]
1050pub struct KiloConfig {
1051    pub enabled: bool,
1052    pub api_key_env: String,
1053    pub api_key: Option<String>,
1054    /// Optional Kilo organization id — scopes the balance to a team via the
1055    /// `x-kilocode-organizationid` header. Omit for the personal balance.
1056    pub organization_id: Option<String>,
1057}
1058
1059impl Default for KiloConfig {
1060    fn default() -> Self {
1061        // Opt-in like DeepSeek: requires an explicit API key, so it defaults to
1062        // disabled and never affects existing installs.
1063        Self {
1064            enabled: false,
1065            api_key_env: "KILO_API_KEY".to_string(),
1066            api_key: None,
1067            organization_id: None,
1068        }
1069    }
1070}
1071
1072#[derive(Debug, Clone, Deserialize, Serialize)]
1073#[serde(default)]
1074pub struct NovitaConfig {
1075    pub enabled: bool,
1076    pub api_key_env: String,
1077    pub api_key: Option<String>,
1078}
1079
1080impl Default for NovitaConfig {
1081    fn default() -> Self {
1082        // Opt-in like DeepSeek/Kilo: needs an explicit API key.
1083        Self {
1084            enabled: false,
1085            api_key_env: "NOVITA_API_KEY".to_string(),
1086            api_key: None,
1087        }
1088    }
1089}
1090
1091#[derive(Debug, Clone, Deserialize, Serialize)]
1092#[serde(default)]
1093pub struct MinimaxConfig {
1094    pub enabled: bool,
1095    pub api_key_env: String,
1096    pub api_key: Option<String>,
1097    /// `"global"` → api.minimax.io; `"cn"` → api.minimaxi.com. Unlike
1098    /// Moonshot's, this does not change the unit — MiniMax reports quota as a
1099    /// percentage either way. It picks the *instance*: a key issued for one
1100    /// host is rejected by the other (`status_code 2049`), so pointing this at
1101    /// the wrong region reads as an invalid key rather than an empty plan.
1102    pub region: String,
1103}
1104
1105impl Default for MinimaxConfig {
1106    fn default() -> Self {
1107        // Opt-in like the other API-key vendors: needs an explicit key.
1108        Self {
1109            enabled: false,
1110            api_key_env: "MINIMAX_API_KEY".to_string(),
1111            api_key: None,
1112            region: "global".to_string(),
1113        }
1114    }
1115}
1116
1117#[derive(Debug, Clone, Deserialize, Serialize)]
1118#[serde(default)]
1119pub struct MoonshotConfig {
1120    pub enabled: bool,
1121    pub api_key_env: String,
1122    pub api_key: Option<String>,
1123    /// `"global"` → api.moonshot.ai (USD); `"cn"` → api.moonshot.cn (CNY).
1124    pub region: String,
1125}
1126
1127impl Default for MoonshotConfig {
1128    fn default() -> Self {
1129        // Opt-in like DeepSeek/Kilo/Novita: needs an explicit API key.
1130        Self {
1131            enabled: false,
1132            api_key_env: "MOONSHOT_API_KEY".to_string(),
1133            api_key: None,
1134            region: "global".to_string(),
1135        }
1136    }
1137}
1138
1139#[derive(Debug, Clone, Deserialize, Serialize)]
1140#[serde(default)]
1141pub struct GrokConfig {
1142    pub enabled: bool,
1143    /// Env var for the xAI **Management** key (distinct from the inference key).
1144    pub api_key_env: String,
1145    pub api_key: Option<String>,
1146    /// Optional team id. When absent, it's auto-resolved from the management
1147    /// key via `/auth/management-keys/validation`.
1148    pub team_id: Option<String>,
1149}
1150
1151impl Default for GrokConfig {
1152    fn default() -> Self {
1153        // Opt-in: needs a management key (and, for prepaid, a team).
1154        Self {
1155            enabled: false,
1156            api_key_env: "XAI_MANAGEMENT_KEY".to_string(),
1157            api_key: None,
1158            team_id: None,
1159        }
1160    }
1161}
1162
1163/// SuperGrok subscription auth — no API key of its own. Billing and banked
1164/// resets use the `key` already in Grok Build's `auth.json` (read-only).
1165/// Login, issuer, proxy, and token rotation stay inside Grok Build.
1166///
1167/// Opt-in like Cursor/Kiro (`enabled` defaults to `false`): it requires a
1168/// separate official executable and signed-in session, so it stays off until
1169/// the user explicitly turns it on.
1170#[derive(Debug, Clone, Deserialize, Serialize)]
1171#[serde(default)]
1172pub struct SuperGrokConfig {
1173    pub enabled: bool,
1174    /// Trusted official Grok Build executable. Defaults to its canonical
1175    /// `$GROK_HOME/bin/grok` (or `~/.grok/bin/grok`) installation path instead
1176    /// of searching PATH, where unrelated programs can share the name.
1177    pub grok_binary: PathBuf,
1178    /// Opaque auth/config files used only to fingerprint the active cache
1179    /// scope. Their contents are never parsed or copied to the cache.
1180    pub auth_path: Option<PathBuf>,
1181    pub config_path: Option<PathBuf>,
1182}
1183
1184impl Default for SuperGrokConfig {
1185    fn default() -> Self {
1186        Self {
1187            enabled: false,
1188            grok_binary: default_grok_binary(),
1189            auth_path: None,
1190            config_path: None,
1191        }
1192    }
1193}
1194
1195/// Grok Bot — the desktop app's weekly included-usage pool, from its own
1196/// Connect-RPC dashboard call. Distinct from `[grok]` (Management API prepaid
1197/// dollars) and `[supergrok]` (Grok Build subscription). No API key: the
1198/// credential is the app's own session in `sand-secrets.json` (read-only).
1199/// Linux and macOS; Windows fails closed at fetch time.
1200#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1201#[serde(default)]
1202pub struct GrokbotConfig {
1203    /// Opt-in (defaults to `false`), like every vendor riding a local app's
1204    /// session.
1205    pub enabled: bool,
1206    /// Override for the app's credential file (default
1207    /// `~/.config/Grok Bot/sand-secrets.json` on Linux,
1208    /// `~/Library/Application Support/Grok Bot/sand-secrets.json` on macOS),
1209    /// mirroring `[cursor] db_path` and `[kimi] credentials_path`.
1210    pub secrets_path: Option<PathBuf>,
1211}
1212
1213fn default_grok_binary() -> PathBuf {
1214    let executable = if cfg!(windows) { "grok.exe" } else { "grok" };
1215    let grok_home = std::env::var_os("GROK_HOME")
1216        .filter(|value| !value.is_empty())
1217        .map(PathBuf::from)
1218        .or_else(|| crate::cache::home_dir().ok().map(|home| home.join(".grok")));
1219    grok_home
1220        .map(|home| home.join("bin").join(executable))
1221        .unwrap_or_else(|| PathBuf::from(executable))
1222}
1223
1224/// Antigravity reads its quota from a usable local Antigravity product. When no
1225/// product is up — or `agy` requires the CSRF token it does not publish — it
1226/// falls back to the Google session Antigravity saved in the OS keyring and
1227/// talks to Cloud Code directly. Renewing that session needs Antigravity's
1228/// OAuth client id and secret, which are not shipped in source: set them here
1229/// (they are public installed-app credentials) or the fallback only lasts as
1230/// long as the saved access token does.
1231#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1232#[serde(default)]
1233pub struct AntigravityConfig {
1234    pub enabled: bool,
1235    /// OAuth client id used to refresh the keyring session.
1236    pub oauth_client_id: Option<String>,
1237    /// OAuth client secret paired with `oauth_client_id`. An
1238    /// installed-app secret is not confidential by Google's definition, but
1239    /// it is still treated as an inline credential for file-permission purposes.
1240    pub oauth_client_secret: Option<String>,
1241}
1242
1243/// Cursor reads its quota through a session token the Cursor IDE already
1244/// wrote to its local `state.vscdb` — no API key, but (unlike Antigravity)
1245/// there is a real on-disk path that can need overriding (e.g. a portable or
1246/// non-default Cursor install), mirroring `openai.codex_auth_path`.
1247///
1248/// Opt-in like DeepSeek/Kilo/etc (`enabled` defaults to `false`, matching
1249/// `bool::default()`): reads an undocumented endpoint via a session token
1250/// scraped from a local IDE file, so it stays off until the user explicitly
1251/// turns it on.
1252#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1253#[serde(default)]
1254pub struct CursorConfig {
1255    pub enabled: bool,
1256    /// Override Cursor's local state database path (defaults to the
1257    /// platform-standard `.../User/globalStorage/state.vscdb` — see
1258    /// `cursor::db::default_db_path`).
1259    pub db_path: Option<PathBuf>,
1260    /// Override the headless `cursor-agent` CLI's own login file (defaults to
1261    /// `.../cursor/auth.json` — see `cursor::db::default_agent_auth_path`).
1262    /// Used as a fallback when `db_path` doesn't exist, so a text-only
1263    /// machine that never runs the desktop IDE still gets usage.
1264    pub agent_auth_path: Option<PathBuf>,
1265}
1266
1267/// Kiro CLI reads its quota through the AWS SSO OIDC session kiro-cli already
1268/// wrote to its own local `data.sqlite3` — no API key, but (like Cursor) a
1269/// real on-disk path that can need overriding.
1270///
1271/// Opt-in like Cursor/DeepSeek/Kilo/etc (`enabled` defaults to `false`):
1272/// calls a reverse-engineered CodeWhisperer endpoint via a session token
1273/// scraped from a local CLI database, so it stays off until the user
1274/// explicitly turns it on.
1275#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1276#[serde(default)]
1277pub struct KiroConfig {
1278    pub enabled: bool,
1279    /// Override kiro-cli's local database path (defaults to the
1280    /// platform-standard `.../kiro-cli/data.sqlite3` — see
1281    /// `kiro::db::default_db_path`).
1282    pub db_path: Option<PathBuf>,
1283}
1284
1285#[derive(Debug, Clone, Deserialize, Serialize)]
1286#[serde(default)]
1287pub struct AnthropicApiConfig {
1288    pub enabled: bool,
1289    /// Env var for the Console **Admin key** (`sk-ant-admin01-…`), distinct from
1290    /// an inference key and from the Claude Code OAuth login.
1291    pub api_key_env: String,
1292    pub api_key: Option<String>,
1293    /// Monthly USD spend limit, used only for the spend-vs-limit % display. The
1294    /// API exposes neither this limit nor the remaining prepaid balance.
1295    pub monthly_limit: Option<f64>,
1296}
1297
1298impl Default for AnthropicApiConfig {
1299    fn default() -> Self {
1300        // Opt-in: needs an explicit Admin key.
1301        Self {
1302            enabled: false,
1303            api_key_env: "ANTHROPIC_ADMIN_KEY".to_string(),
1304            api_key: None,
1305            monthly_limit: None,
1306        }
1307    }
1308}
1309
1310/// A user-defined HTTP provider: one GET with a static token, projected onto
1311/// the shared report shape through RFC 6901 JSON Pointers.
1312///
1313/// Everything a built-in vendor hard-codes is a field here, which is why this
1314/// type validates so much more than the others: a typo in `[deepseek]` hits a
1315/// fixed endpoint and fails loudly, while a typo here quietly sends the user's
1316/// key to the wrong host. The `id` doubles as the cache directory name and the
1317/// `--vendor` selector, so it is held to the character class of the built-in
1318/// slugs.
1319#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
1320#[serde(default, remote = "Self")]
1321pub struct CustomProviderConfig {
1322    /// `[a-z0-9][a-z0-9_-]{0,31}`; unique, and never a built-in vendor's slug.
1323    pub id: String,
1324    /// Display name, 1 to 48 characters. Defaults to `id`.
1325    pub name: String,
1326    /// Exactly three lowercase ASCII letters, unique across built-in vendors
1327    /// and other custom providers — it is the `{vendor_short}` bar tag.
1328    pub short_name: String,
1329    /// A built-in vendor slug whose mark supporting frontends may use.
1330    /// `None` preserves the custom provider's `short_name` tag.
1331    pub brand: Option<String>,
1332    pub enabled: bool,
1333    /// `https://` unless `allow_http`; never carries `user:pass@`.
1334    pub url: String,
1335    pub allow_http: bool,
1336    /// Env var read first; `""` means the inline `api_key` is the only source.
1337    pub api_key_env: String,
1338    pub api_key: Option<String>,
1339    /// The header that carries the key.
1340    pub auth_header: String,
1341    /// Sent as `"<scheme> <key>"`; `""` sends the bare key.
1342    pub auth_scheme: String,
1343    /// Extra non-secret headers.
1344    pub headers: BTreeMap<String, String>,
1345    /// Literal plan label.
1346    pub plan: Option<String>,
1347    /// Pointer to the plan label in the response; wins over `plan`.
1348    pub plan_path: Option<String>,
1349    /// Must be within `10..=3600`.
1350    pub cache_ttl_secs: u64,
1351    pub metrics: Vec<CustomMetricSpec>,
1352    pub texts: Vec<CustomTextSpec>,
1353}
1354
1355impl Default for CustomProviderConfig {
1356    fn default() -> Self {
1357        Self {
1358            id: String::new(),
1359            name: String::new(),
1360            short_name: String::new(),
1361            brand: None,
1362            enabled: false,
1363            url: String::new(),
1364            allow_http: false,
1365            api_key_env: String::new(),
1366            api_key: None,
1367            auth_header: "Authorization".to_string(),
1368            auth_scheme: "Bearer".to_string(),
1369            headers: BTreeMap::new(),
1370            plan: None,
1371            plan_path: None,
1372            cache_ttl_secs: 60,
1373            metrics: Vec::new(),
1374            texts: Vec::new(),
1375        }
1376    }
1377}
1378
1379/// `name` defaults to `id`, which a per-field serde default cannot express (a
1380/// default sees no sibling field). The derive is routed through
1381/// `remote = "Self"` so the fill-in happens here, on every parse path, rather
1382/// than only in `Config::load_from`.
1383impl<'de> Deserialize<'de> for CustomProviderConfig {
1384    fn deserialize<D: serde::Deserializer<'de>>(
1385        deserializer: D,
1386    ) -> std::result::Result<Self, D::Error> {
1387        let mut this = Self::deserialize(deserializer)?;
1388        if this.name.is_empty() {
1389            this.name = this.id.clone();
1390        }
1391        Ok(this)
1392    }
1393}
1394
1395impl Serialize for CustomProviderConfig {
1396    fn serialize<S: serde::Serializer>(
1397        &self,
1398        serializer: S,
1399    ) -> std::result::Result<S::Ok, S::Error> {
1400        Self::serialize(self, serializer)
1401    }
1402}
1403
1404/// One percentage row. Either `percent` alone, or `used` and `limit`
1405/// together — never a mix, so a row cannot show a percentage from one field
1406/// and a footnote from another.
1407#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
1408#[serde(default)]
1409pub struct CustomMetricSpec {
1410    pub label: String,
1411    pub used: Option<String>,
1412    pub limit: Option<String>,
1413    pub percent: Option<String>,
1414    /// Pointer to an RFC 3339 string or a Unix epoch (seconds or milliseconds).
1415    pub resets_at: Option<String>,
1416    /// Window length for pacing, at least 60.
1417    pub window_secs: Option<u64>,
1418}
1419
1420/// One free-text row: a string, number, or boolean at `value`.
1421#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
1422#[serde(default)]
1423pub struct CustomTextSpec {
1424    pub label: String,
1425    pub value: String,
1426}
1427
1428impl CustomProviderConfig {
1429    /// The TOML locator for error messages: `[[custom]] id = "mytool"`.
1430    pub fn section_label(&self) -> String {
1431        format!("[[custom]] id = {:?}", self.id)
1432    }
1433
1434    /// Env var (when `api_key_env` is set) → inline `api_key` → a
1435    /// `Credentials` error that names the section and never the key.
1436    pub fn resolve_api_key(&self) -> Result<String> {
1437        if let Some(key) = optional_api_key(&self.api_key_env, self.api_key.as_deref()) {
1438            return Ok(key);
1439        }
1440        let advice = if self.api_key_env.is_empty() {
1441            "set `api_key`, or name an environment variable in `api_key_env`".to_string()
1442        } else {
1443            format!("export {} or set `api_key`", self.api_key_env)
1444        };
1445        Err(AppError::Credentials(format!(
1446            "custom {}: no API key. Either {advice} under {} in {}.",
1447            self.id,
1448            self.section_label(),
1449            config_path_hint()
1450        )))
1451    }
1452
1453    pub fn cache_ttl(&self) -> std::time::Duration {
1454        std::time::Duration::from_secs(self.cache_ttl_secs)
1455    }
1456
1457    /// Every rule that serde cannot express, each naming the section. Runs
1458    /// for disabled entries too: a broken entry is a broken config, and the
1459    /// day it is enabled is the wrong day to find out.
1460    fn validate(&self, index: usize) -> Result<()> {
1461        if !is_valid_custom_id(&self.id) {
1462            return Err(AppError::Other(format!(
1463                "[[custom]] entry #{}: id {:?} must match [a-z0-9][a-z0-9_-]{{0,31}}",
1464                index + 1,
1465                self.id
1466            )));
1467        }
1468        let section = self.section_label();
1469        let bad = |msg: String| AppError::Other(format!("{section}: {msg}"));
1470
1471        if VendorId::all().iter().any(|v| v.slug() == self.id) {
1472            return Err(bad(format!("id {:?} is a built-in vendor", self.id)));
1473        }
1474        let name_len = self.name.chars().count();
1475        if name_len == 0 || name_len > 48 || self.name.chars().any(char::is_control) {
1476            return Err(bad(
1477                "name must be 1 to 48 characters without control characters".into(),
1478            ));
1479        }
1480        if self.short_name.len() != 3 || !self.short_name.bytes().all(|b| b.is_ascii_lowercase()) {
1481            return Err(bad(format!(
1482                "short_name {:?} must be exactly 3 lowercase ASCII letters",
1483                self.short_name
1484            )));
1485        }
1486        if let Some(brand) = &self.brand
1487            && !VendorId::all().iter().any(|v| v.slug() == brand)
1488        {
1489            return Err(bad(format!(
1490                "brand {brand:?} must name a built-in vendor (it borrows that \
1491                 vendor's mark); leave it unset to keep the short_name tag"
1492            )));
1493        }
1494        let url = reqwest::Url::parse(&self.url)
1495            .map_err(|_| bad(format!("url {:?} is not a valid URL", self.url)))?;
1496        match url.scheme() {
1497            "https" => {}
1498            "http" if self.allow_http => {}
1499            "http" => {
1500                return Err(bad(
1501                    "url must use https:// (set allow_http = true to permit http://)".into(),
1502                ));
1503            }
1504            other => return Err(bad(format!("url scheme {other:?} is not http or https"))),
1505        }
1506        if !url.username().is_empty() || url.password().is_some() {
1507            return Err(bad("url must not carry credentials (user:pass@)".into()));
1508        }
1509        if url.host_str().is_none() {
1510            return Err(bad("url has no host".into()));
1511        }
1512        if !self.api_key_env.is_empty() && !is_valid_env_var_name(&self.api_key_env) {
1513            return Err(bad(format!(
1514                "api_key_env {:?} is not a valid environment variable name",
1515                self.api_key_env
1516            )));
1517        }
1518        validate_header_name(&section, "auth_header", &self.auth_header)?;
1519        if reqwest::header::HeaderValue::from_str(&format!("{} k", self.auth_scheme)).is_err() {
1520            return Err(bad(
1521                "auth_scheme contains characters that are not valid in an HTTP header".into(),
1522            ));
1523        }
1524        for (name, value) in &self.headers {
1525            validate_header_name(&section, "headers", name)?;
1526            if name.eq_ignore_ascii_case(&self.auth_header) {
1527                return Err(bad(format!(
1528                    "headers must not repeat auth_header {:?}",
1529                    self.auth_header
1530                )));
1531            }
1532            if reqwest::header::HeaderValue::from_str(value).is_err() {
1533                return Err(bad(format!(
1534                    "header {name:?} has a value that is not valid in an HTTP header"
1535                )));
1536            }
1537        }
1538        if let Some(plan) = &self.plan {
1539            validate_custom_label(&section, "plan", plan)?;
1540        }
1541        if let Some(pointer) = &self.plan_path {
1542            validate_pointer(&section, "plan_path", pointer)?;
1543        }
1544        if !(10..=3600).contains(&self.cache_ttl_secs) {
1545            return Err(bad(format!(
1546                "cache_ttl_secs must be between 10 and 3600, got {}",
1547                self.cache_ttl_secs
1548            )));
1549        }
1550        if self.metrics.is_empty() && self.texts.is_empty() {
1551            return Err(bad(
1552                "needs at least one [[custom.metrics]] or [[custom.texts]] entry".into(),
1553            ));
1554        }
1555        let mut metric_labels = HashSet::new();
1556        for metric in &self.metrics {
1557            validate_custom_label(&section, "metric label", &metric.label)?;
1558            if !metric_labels.insert(metric.label.as_str()) {
1559                return Err(bad(format!("duplicate metric label {:?}", metric.label)));
1560            }
1561            let pair = (metric.used.is_some(), metric.limit.is_some());
1562            let well_formed = if metric.percent.is_some() {
1563                pair == (false, false)
1564            } else {
1565                pair == (true, true)
1566            };
1567            if !well_formed {
1568                return Err(bad(format!(
1569                    "metric {:?} must set `percent`, or both `used` and `limit` (not a mix)",
1570                    metric.label
1571                )));
1572            }
1573            for (field, pointer) in [
1574                ("used", &metric.used),
1575                ("limit", &metric.limit),
1576                ("percent", &metric.percent),
1577                ("resets_at", &metric.resets_at),
1578            ] {
1579                if let Some(pointer) = pointer {
1580                    validate_pointer(&section, field, pointer)?;
1581                }
1582            }
1583            if let Some(secs) = metric.window_secs
1584                && secs < 60
1585            {
1586                return Err(bad(format!(
1587                    "metric {:?} window_secs must be at least 60, got {secs}",
1588                    metric.label
1589                )));
1590            }
1591        }
1592        let mut text_labels = HashSet::new();
1593        for text in &self.texts {
1594            validate_custom_label(&section, "text label", &text.label)?;
1595            if !text_labels.insert(text.label.as_str()) {
1596                return Err(bad(format!("duplicate text label {:?}", text.label)));
1597            }
1598            validate_pointer(&section, "value", &text.value)?;
1599        }
1600        Ok(())
1601    }
1602}
1603
1604fn is_valid_custom_id(id: &str) -> bool {
1605    let bytes = id.as_bytes();
1606    let Some(&first) = bytes.first() else {
1607        return false;
1608    };
1609    bytes.len() <= 32
1610        && (first.is_ascii_lowercase() || first.is_ascii_digit())
1611        && bytes
1612            .iter()
1613            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-'))
1614}
1615
1616fn validate_pointer(section: &str, field: &str, pointer: &str) -> Result<()> {
1617    if !pointer.starts_with('/') || pointer.chars().any(char::is_control) {
1618        return Err(AppError::Other(format!(
1619            "{section}: {field} {pointer:?} must be an RFC 6901 JSON Pointer starting with '/'"
1620        )));
1621    }
1622    Ok(())
1623}
1624
1625fn validate_custom_label(section: &str, field: &str, label: &str) -> Result<()> {
1626    let len = label.chars().count();
1627    if len == 0 || len > 64 || label.chars().any(char::is_control) {
1628        return Err(AppError::Other(format!(
1629            "{section}: {field} {label:?} must be 1 to 64 characters without control characters"
1630        )));
1631    }
1632    Ok(())
1633}
1634
1635fn validate_header_name(section: &str, field: &str, name: &str) -> Result<()> {
1636    if name.is_empty() || reqwest::header::HeaderName::from_bytes(name.as_bytes()).is_err() {
1637        return Err(AppError::Other(format!(
1638            "{section}: {field} {name:?} is not a valid HTTP header name"
1639        )));
1640    }
1641    Ok(())
1642}
1643
1644/// Resolve an API key for a vendor: a valid env-var name wins, then inline
1645/// config, then a clear error naming both fields. Used by every API-key vendor.
1646pub fn resolve_api_key(
1647    vendor_label: &str,
1648    env_var_name: &str,
1649    inline: Option<&str>,
1650) -> crate::error::Result<String> {
1651    let section = match vendor_label {
1652        "OpenCode Go" => "[opencode-go]".to_string(),
1653        _ => format!("[{}]", vendor_label.to_lowercase()),
1654    };
1655    resolve_api_key_in_section(vendor_label, &section, env_var_name, inline)
1656}
1657
1658/// The env-then-inline lookup without the "or fail" ending, for vendors where
1659/// an absent API key is a legitimate state rather than an error — Kimi accepts
1660/// a Kimi Code CLI subscription login instead.
1661pub fn optional_api_key(env_var_name: &str, inline: Option<&str>) -> Option<String> {
1662    if is_valid_env_var_name(env_var_name)
1663        && let Ok(v) = std::env::var(env_var_name)
1664        && !v.is_empty()
1665    {
1666        return Some(v);
1667    }
1668    inline.filter(|v| !v.is_empty()).map(str::to_string)
1669}
1670
1671fn resolve_api_key_in_section(
1672    vendor_label: &str,
1673    section: &str,
1674    env_var_name: &str,
1675    inline: Option<&str>,
1676) -> crate::error::Result<String> {
1677    if let Some(key) = optional_api_key(env_var_name, inline) {
1678        return Ok(key);
1679    }
1680    let valid_env_name = is_valid_env_var_name(env_var_name);
1681    let advice = if valid_env_name {
1682        "set an API key in a valid environment variable or set `api_key`"
1683    } else {
1684        "fix the invalid `api_key_env` with a valid environment variable name or set `api_key`"
1685    };
1686    Err(crate::error::AppError::Credentials(format!(
1687        "{vendor_label}: no API key. Either {advice} under {section} in {}.",
1688        config_path_hint()
1689    )))
1690}
1691
1692pub(crate) fn is_valid_env_var_name(name: &str) -> bool {
1693    let mut chars = name.chars();
1694    let Some(first) = chars.next() else {
1695        return false;
1696    };
1697    (first.is_ascii_alphabetic() || first == '_')
1698        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
1699}
1700
1701impl Config {
1702    /// Load from `~/.config/ai-usagebar/config.toml`. Returns defaults if the
1703    /// file doesn't exist; errors only on actual parse failures.
1704    pub fn load() -> Result<Self> {
1705        let Some(path) = resolved_path() else {
1706            return Ok(Self::default());
1707        };
1708        Self::load_from(&path)
1709    }
1710
1711    pub fn load_from(path: &std::path::Path) -> Result<Self> {
1712        match std::fs::read_to_string(path) {
1713            Ok(s) => {
1714                let mut config: Self = toml::from_str(&s)?;
1715                // `~` is shell syntax, not path syntax: `PathBuf` keeps it
1716                // literally, so a documented `credentials_path = "~/..."`
1717                // silently pointed at a directory named `~`.
1718                config.expand_paths();
1719                config.validate()?;
1720                #[cfg(unix)]
1721                config.protect_inline_secrets(path)?;
1722                // A custom provider's token variable is as secret as any
1723                // built-in one; subprocesses (`gh`, `grok`, `claude`) must
1724                // not inherit it.
1725                crate::vendor::register_secret_env_vars(&config.custom_secret_env_vars());
1726                Ok(config)
1727            }
1728            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
1729            Err(e) => Err(AppError::io_at(path, e)),
1730        }
1731    }
1732
1733    fn expand_paths(&mut self) {
1734        expand_tilde_opt(&mut self.context.projects_path);
1735        expand_tilde_opt(&mut self.anthropic.credentials_path);
1736        expand_tilde_opt(&mut self.anthropic.accounts_dir);
1737        expand_tilde_opt(&mut self.anthropic.desktop_profiles_dir);
1738        expand_tilde_opt(&mut self.openai.codex_auth_path);
1739        expand_tilde_opt(&mut self.cursor.db_path);
1740        expand_tilde_opt(&mut self.cursor.agent_auth_path);
1741        expand_tilde_opt(&mut self.kiro.db_path);
1742        expand_tilde_opt(&mut self.kimi.credentials_path);
1743        expand_tilde_opt(&mut self.grokbot.secrets_path);
1744        self.supergrok.grok_binary = expand_tilde(&self.supergrok.grok_binary);
1745        expand_tilde_opt(&mut self.supergrok.auth_path);
1746        expand_tilde_opt(&mut self.supergrok.config_path);
1747        for account in &mut self.anthropic.accounts {
1748            account.credentials_path = expand_tilde(&account.credentials_path);
1749        }
1750        for account in &mut self.openai.accounts {
1751            account.codex_auth_path = expand_tilde(&account.codex_auth_path);
1752        }
1753    }
1754
1755    /// Explicitly enumerate every inline credential field. Adding a new
1756    /// credential vendor must add it here so its config receives the same
1757    /// protection.
1758    #[cfg(unix)]
1759    fn has_inline_secrets(&self) -> bool {
1760        [
1761            self.zai.api_key.as_deref(),
1762            self.openrouter.api_key.as_deref(),
1763            self.deepseek.api_key.as_deref(),
1764            self.kimi.api_key.as_deref(),
1765            self.kilo.api_key.as_deref(),
1766            self.novita.api_key.as_deref(),
1767            self.minimax.api_key.as_deref(),
1768            self.moonshot.api_key.as_deref(),
1769            self.grok.api_key.as_deref(),
1770            self.anthropic_api.api_key.as_deref(),
1771            self.opencode_go.api_key.as_deref(),
1772            self.antigravity.oauth_client_secret.as_deref(),
1773        ]
1774        .into_iter()
1775        .chain(
1776            self.openrouter
1777                .accounts
1778                .iter()
1779                .map(|account| account.api_key.as_deref()),
1780        )
1781        .chain(self.custom.iter().map(|c| c.api_key.as_deref()))
1782        .any(|key| key.is_some_and(|key| !key.is_empty()))
1783    }
1784
1785    fn custom_secret_env_vars(&self) -> Vec<String> {
1786        self.custom
1787            .iter()
1788            .filter(|c| !c.api_key_env.is_empty())
1789            .map(|c| c.api_key_env.clone())
1790            .collect()
1791    }
1792
1793    /// The `[[custom]]` providers that are switched on, in config order.
1794    pub fn enabled_custom(&self) -> impl Iterator<Item = &CustomProviderConfig> {
1795        self.custom.iter().filter(|c| c.enabled)
1796    }
1797
1798    /// A `[[custom]]` provider by `id`, enabled or not.
1799    pub fn custom_by_id(&self, id: &str) -> Option<&CustomProviderConfig> {
1800        self.custom.iter().find(|c| c.id == id)
1801    }
1802
1803    #[cfg(unix)]
1804    fn protect_inline_secrets(&self, path: &Path) -> Result<()> {
1805        if !self.has_inline_secrets() {
1806            return Ok(());
1807        }
1808
1809        let metadata = std::fs::metadata(path).map_err(|_| {
1810            AppError::Credentials(format!(
1811                "config at {} contains inline credentials but its permissions could not be checked; fix permissions or move credentials to environment variables",
1812                path.display()
1813            ))
1814        })?;
1815        if inline_key_permission_decision(metadata.mode()) == InlineKeyPermissionDecision::Tighten {
1816            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|_| {
1817                AppError::Credentials(format!(
1818                    "config at {} contains inline credentials but is group/other-readable and could not be tightened to 0600; fix permissions or move credentials to environment variables",
1819                    path.display()
1820                ))
1821            })?;
1822        }
1823        Ok(())
1824    }
1825
1826    pub fn is_enabled(&self, id: VendorId) -> bool {
1827        match id {
1828            VendorId::Anthropic => self.anthropic.enabled,
1829            VendorId::AnthropicApi => self.anthropic_api.enabled,
1830            VendorId::Openai => self.openai.enabled,
1831            VendorId::Copilot => self.copilot.enabled,
1832            VendorId::Zai => self.zai.enabled,
1833            VendorId::Openrouter => self.openrouter.enabled,
1834            VendorId::Deepseek => self.deepseek.enabled,
1835            VendorId::Kimi => self.kimi.enabled,
1836            VendorId::Kilo => self.kilo.enabled,
1837            VendorId::Novita => self.novita.enabled,
1838            VendorId::Moonshot => self.moonshot.enabled,
1839            VendorId::Grok => self.grok.enabled,
1840            VendorId::Supergrok => self.supergrok.enabled,
1841            VendorId::Grokbot => self.grokbot.enabled,
1842            VendorId::Antigravity => self.antigravity.enabled,
1843            VendorId::Cursor => self.cursor.enabled,
1844            VendorId::Minimax => self.minimax.enabled,
1845            VendorId::Kiro => self.kiro.enabled,
1846            VendorId::NousResearch => self.nous.enabled,
1847            VendorId::OpenCodeGo => self.opencode_go.enabled,
1848            VendorId::CommandCode => self.commandcode.enabled,
1849            VendorId::Ollama => self.ollama.enabled,
1850        }
1851    }
1852
1853    /// The environment variable this provider's API key is read from, honoring
1854    /// a per-vendor `api_key_env` override; `""` for a provider that takes no
1855    /// key. Matching on [`VendorId`] rather than on a section name is
1856    /// deliberate: a new key vendor that nobody adds here fails to compile,
1857    /// where a `_ =>` arm over `&str` sections would silently hand back the
1858    /// wrong default and report the provider as unconfigured for ever.
1859    pub fn api_key_env_for(&self, id: VendorId) -> &str {
1860        match id {
1861            VendorId::AnthropicApi => &self.anthropic_api.api_key_env,
1862            VendorId::Zai => &self.zai.api_key_env,
1863            VendorId::Openrouter => &self.openrouter.api_key_env,
1864            VendorId::Deepseek => &self.deepseek.api_key_env,
1865            VendorId::Kimi => &self.kimi.api_key_env,
1866            VendorId::Kilo => &self.kilo.api_key_env,
1867            VendorId::Novita => &self.novita.api_key_env,
1868            VendorId::Moonshot => &self.moonshot.api_key_env,
1869            VendorId::Grok => &self.grok.api_key_env,
1870            VendorId::Minimax => &self.minimax.api_key_env,
1871            VendorId::OpenCodeGo => &self.opencode_go.api_key_env,
1872            VendorId::Ollama => &self.ollama.api_key_env,
1873            // Fixed names: OAuth-first providers whose environment override is
1874            // not user-renameable, and the providers with no key at all.
1875            VendorId::Anthropic
1876            | VendorId::Openai
1877            | VendorId::Copilot
1878            | VendorId::Supergrok
1879            | VendorId::Grokbot
1880            | VendorId::Antigravity
1881            | VendorId::Cursor
1882            | VendorId::Kiro
1883            | VendorId::NousResearch
1884            | VendorId::CommandCode => id.api_key_env(),
1885        }
1886    }
1887
1888    /// A non-empty inline `api_key` from this provider's config section. An
1889    /// empty string counts as unset, the same way the vendors' own
1890    /// `resolve_api_key` treats it.
1891    pub fn inline_api_key(&self, id: VendorId) -> Option<&str> {
1892        let raw = match id {
1893            VendorId::AnthropicApi => self.anthropic_api.api_key.as_deref(),
1894            VendorId::Zai => self.zai.api_key.as_deref(),
1895            VendorId::Openrouter => self.openrouter.api_key.as_deref(),
1896            VendorId::Deepseek => self.deepseek.api_key.as_deref(),
1897            VendorId::Kimi => self.kimi.api_key.as_deref(),
1898            VendorId::Kilo => self.kilo.api_key.as_deref(),
1899            VendorId::Novita => self.novita.api_key.as_deref(),
1900            VendorId::Moonshot => self.moonshot.api_key.as_deref(),
1901            VendorId::Grok => self.grok.api_key.as_deref(),
1902            VendorId::Minimax => self.minimax.api_key.as_deref(),
1903            VendorId::OpenCodeGo => self.opencode_go.api_key.as_deref(),
1904            VendorId::Ollama => self.ollama.api_key.as_deref(),
1905            VendorId::Anthropic
1906            | VendorId::Openai
1907            | VendorId::Copilot
1908            | VendorId::Supergrok
1909            | VendorId::Grokbot
1910            | VendorId::Antigravity
1911            | VendorId::Cursor
1912            | VendorId::Kiro
1913            | VendorId::NousResearch
1914            | VendorId::CommandCode => None,
1915        };
1916        raw.filter(|key| !key.is_empty())
1917    }
1918
1919    pub fn enabled_vendors(&self) -> Vec<VendorId> {
1920        VendorId::all()
1921            .iter()
1922            .copied()
1923            .filter(|id| self.is_enabled(*id))
1924            .collect()
1925    }
1926
1927    /// Validate cross-entry constraints that serde cannot express. Account
1928    /// labels are both CLI selectors and TUI tab identities, so duplicates
1929    /// would make either destination ambiguous.
1930    pub fn validate(&self) -> Result<()> {
1931        if let Some(minutes) = self.tray.refresh_minutes
1932            && !TRAY_REFRESH_MINUTES.contains(&minutes)
1933        {
1934            return Err(AppError::Other(format!(
1935                "[tray] refresh_minutes must be one of 1, 5 or 10, got {minutes}"
1936            )));
1937        }
1938        if self.context.context_window_tokens == Some(0) {
1939            return Err(AppError::Other(
1940                "[context] context_window_tokens must be greater than zero".into(),
1941            ));
1942        }
1943        for (model, tokens) in &self.context.model_context_window_tokens {
1944            if model.trim().is_empty() {
1945                return Err(AppError::Other(
1946                    "[context] model_context_window_tokens keys must not be empty".into(),
1947                ));
1948            }
1949            if *tokens == 0 {
1950                return Err(AppError::Other(format!(
1951                    "[context] model_context_window_tokens entry {model:?} must be greater than zero"
1952                )));
1953            }
1954        }
1955        if let Some(limit) = self.anthropic_api.monthly_limit
1956            && (!limit.is_finite() || limit <= 0.0)
1957        {
1958            return Err(AppError::Other(
1959                "[anthropic_api] monthly_limit must be finite and greater than zero; \
1960                 remove it to show spend without a limit"
1961                    .into(),
1962            ));
1963        }
1964        if crate::kimi::oauth::Region::parse(&self.kimi.region).is_none()
1965            && !self.kimi.region.eq_ignore_ascii_case("auto")
1966        {
1967            return Err(AppError::Other(format!(
1968                "[kimi] region must be \"auto\", \"cn\", or \"global\", got {:?}",
1969                self.kimi.region
1970            )));
1971        }
1972        if !self.minimax.region.eq_ignore_ascii_case("global")
1973            && !self.minimax.region.eq_ignore_ascii_case("cn")
1974        {
1975            return Err(AppError::Other(format!(
1976                "[minimax] region must be \"global\" or \"cn\", got {:?}",
1977                self.minimax.region
1978            )));
1979        }
1980        if self.supergrok.grok_binary.as_os_str().is_empty() {
1981            return Err(AppError::Other(
1982                "[supergrok] grok_binary must not be empty".into(),
1983            ));
1984        }
1985        let mut labels = HashSet::new();
1986        for account in &self.anthropic.accounts {
1987            validate_account_label(&account.label)?;
1988            if !labels.insert(&account.label) {
1989                return Err(AppError::Credentials(format!(
1990                    "duplicate anthropic account label {:?}",
1991                    account.label
1992                )));
1993            }
1994        }
1995        let mut openai_labels = HashSet::new();
1996        for account in &self.openai.accounts {
1997            validate_account_label_for("openai", &account.label)?;
1998            if !openai_labels.insert(&account.label) {
1999                return Err(AppError::Credentials(format!(
2000                    "duplicate openai account label {:?}",
2001                    account.label
2002                )));
2003            }
2004        }
2005        let mut openrouter_labels = HashSet::new();
2006        for account in &self.openrouter.accounts {
2007            validate_account_label_for("openrouter", &account.label)?;
2008            if !openrouter_labels.insert(&account.label) {
2009                return Err(AppError::Credentials(format!(
2010                    "duplicate openrouter account label {:?}",
2011                    account.label
2012                )));
2013            }
2014            let has_env = account
2015                .api_key_env
2016                .as_deref()
2017                .is_some_and(|name| !name.is_empty());
2018            let has_inline = account
2019                .api_key
2020                .as_deref()
2021                .is_some_and(|key| !key.is_empty());
2022            if !has_env && !has_inline {
2023                return Err(AppError::Credentials(format!(
2024                    "openrouter account {:?} must set api_key_env or api_key",
2025                    account.label
2026                )));
2027            }
2028        }
2029        self.validate_custom()
2030    }
2031
2032    /// Per-entry rules live on `CustomProviderConfig`; the cross-entry ones —
2033    /// `id` and `short_name` uniqueness, including against the built-in
2034    /// vendors — need the whole list and live here.
2035    fn validate_custom(&self) -> Result<()> {
2036        let mut ids = HashSet::new();
2037        let mut short_names: HashSet<&str> =
2038            VendorId::all().iter().map(|v| v.short_name()).collect();
2039        for (index, custom) in self.custom.iter().enumerate() {
2040            custom.validate(index)?;
2041            if !ids.insert(custom.id.as_str()) {
2042                return Err(AppError::Other(format!(
2043                    "{}: duplicate id",
2044                    custom.section_label()
2045                )));
2046            }
2047            if !short_names.insert(custom.short_name.as_str()) {
2048                return Err(AppError::Other(format!(
2049                    "{}: short_name {:?} is already used by a built-in vendor or another [[custom]] entry",
2050                    custom.section_label(),
2051                    custom.short_name
2052                )));
2053            }
2054        }
2055        Ok(())
2056    }
2057}
2058
2059#[cfg(unix)]
2060#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2061enum InlineKeyPermissionDecision {
2062    Ok,
2063    Tighten,
2064}
2065
2066#[cfg(unix)]
2067fn inline_key_permission_decision(mode: u32) -> InlineKeyPermissionDecision {
2068    if mode & 0o077 == 0 {
2069        InlineKeyPermissionDecision::Ok
2070    } else {
2071        InlineKeyPermissionDecision::Tighten
2072    }
2073}
2074
2075pub fn default_path() -> Option<PathBuf> {
2076    let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
2077    Some(proj.config_dir().join("config.toml"))
2078}
2079
2080/// The Unix-conventional location, which is what every doc, the config
2081/// example, and both desktop integrations have always pointed at. On Linux it
2082/// *is* [`default_path`]; on macOS `ProjectDirs` resolves to
2083/// `~/Library/Application Support/…` instead, so the two diverge.
2084fn legacy_xdg_path() -> Option<PathBuf> {
2085    let home = crate::cache::home_dir().ok()?;
2086    Some(home.join(".config").join("ai-usagebar").join("config.toml"))
2087}
2088
2089/// The config file actually in effect.
2090///
2091/// A `--config` override (see [`set_override_path`]) wins outright so a test
2092/// run never touches the real file. Otherwise [`default_path`] stays
2093/// canonical, but on macOS a file at the documented
2094/// `~/.config/ai-usagebar/config.toml` is honored when the canonical one does
2095/// not exist — otherwise everyone who followed the README (and both desktop
2096/// integrations, which read that path) silently got defaults. The legacy file
2097/// is never moved or rewritten: it may hold API keys, and relocating a secret
2098/// behind the user's back is not this tool's business.
2099pub fn resolved_path() -> Option<PathBuf> {
2100    if let Some(path) = override_path() {
2101        return Some(path);
2102    }
2103    let canonical = default_path();
2104    if let Some(p) = &canonical
2105        && p.exists()
2106    {
2107        return canonical;
2108    }
2109    if let Some(legacy) = legacy_xdg_path()
2110        && legacy.exists()
2111    {
2112        return Some(legacy);
2113    }
2114    canonical
2115}
2116
2117static PATH_OVERRIDE: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
2118
2119/// Point every config load, save, and hint at one explicit file — the
2120/// `--config` flag. Takes precedence over the canonical and legacy locations.
2121/// The file does not have to exist yet: loads treat it as defaults while
2122/// Settings saves create it. Process-wide, so call it once at startup before
2123/// any config is read.
2124pub fn set_override_path(path: &std::path::Path) {
2125    if let Ok(mut slot) = PATH_OVERRIDE.lock() {
2126        *slot = Some(path.to_path_buf());
2127    }
2128}
2129
2130/// Drop the override again. Used only by tests so they can restore the
2131/// process-wide state they changed.
2132#[doc(hidden)]
2133pub fn clear_override_path() {
2134    if let Ok(mut slot) = PATH_OVERRIDE.lock() {
2135        *slot = None;
2136    }
2137}
2138
2139fn override_path() -> Option<PathBuf> {
2140    PATH_OVERRIDE.lock().ok().and_then(|slot| slot.clone())
2141}
2142
2143/// Value of a `--config=PATH` argument, split at the OS-string level so a
2144/// path with bytes Windows/Unix can store but UTF-8 cannot represent (an
2145/// undecodable filename on Unix, a lone surrogate on Windows) survives
2146/// intact instead of being mangled by `to_string_lossy`. `None` when the
2147/// argument is not in that form. Used by both binaries' argv pre-parsers.
2148#[doc(hidden)]
2149pub fn config_flag_value(arg: &std::ffi::OsStr) -> Option<PathBuf> {
2150    #[cfg(unix)]
2151    {
2152        use std::os::unix::ffi::{OsStrExt, OsStringExt};
2153        let rest = arg.as_bytes().strip_prefix(b"--config=")?;
2154        Some(std::ffi::OsString::from_vec(rest.to_vec()).into())
2155    }
2156    #[cfg(windows)]
2157    {
2158        use std::os::windows::ffi::{OsStrExt, OsStringExt};
2159        const PREFIX: &[u16] = &[
2160            b'-' as u16,
2161            b'-' as u16,
2162            b'c' as u16,
2163            b'o' as u16,
2164            b'n' as u16,
2165            b'f' as u16,
2166            b'i' as u16,
2167            b'g' as u16,
2168            b'=' as u16,
2169        ];
2170        let wide: Vec<u16> = arg.encode_wide().collect();
2171        let rest = wide.strip_prefix(PREFIX)?;
2172        Some(std::ffi::OsString::from_wide(rest).into())
2173    }
2174    #[cfg(not(any(unix, windows)))]
2175    {
2176        Some(PathBuf::from(arg.to_str()?.strip_prefix("--config=")?))
2177    }
2178}
2179
2180/// Expand a leading `~` (or `~/`) against the user's home directory. Anything
2181/// else — including `~user` — is left untouched.
2182fn expand_tilde(p: &std::path::Path) -> PathBuf {
2183    let Some(s) = p.to_str() else {
2184        return p.to_path_buf();
2185    };
2186    let rest = if s == "~" {
2187        ""
2188    } else if let Some(r) = s.strip_prefix("~/") {
2189        r
2190    } else {
2191        return p.to_path_buf();
2192    };
2193    match crate::cache::home_dir() {
2194        Ok(home) if rest.is_empty() => home,
2195        Ok(home) => home.join(rest),
2196        Err(_) => p.to_path_buf(),
2197    }
2198}
2199
2200fn expand_tilde_opt(p: &mut Option<PathBuf>) {
2201    if let Some(inner) = p.as_ref() {
2202        *p = Some(expand_tilde(inner));
2203    }
2204}
2205
2206/// Resolved `config.toml` path as a string for user-facing messages. Uses the
2207/// platform's config dir (`directories::ProjectDirs`), so it reads correctly on
2208/// Linux, macOS, and Windows instead of hard-coding the Unix `~/.config` path.
2209/// Falls back to the bare filename if the path can't be resolved.
2210pub fn config_path_hint() -> String {
2211    resolved_path()
2212        .map(|p| p.display().to_string())
2213        .unwrap_or_else(|| "config.toml".to_string())
2214}
2215
2216#[cfg(test)]
2217mod tests {
2218    use super::*;
2219    use std::io::Write;
2220    use tempfile::NamedTempFile;
2221
2222    #[cfg(unix)]
2223    use std::os::unix::fs::{MetadataExt, PermissionsExt};
2224
2225    fn write_toml(s: &str) -> NamedTempFile {
2226        let mut f = NamedTempFile::new().unwrap();
2227        f.write_all(s.as_bytes()).unwrap();
2228        f.flush().unwrap();
2229        f
2230    }
2231
2232    /// The back-compat guarantee #134 asks for: a config with no
2233    /// `[[openai.accounts]]` resolves exactly what it resolved before, whether
2234    /// it sets `codex_auth_path` or leaves it to the default.
2235    #[test]
2236    fn openai_without_accounts_resolves_the_singular_path() {
2237        let explicit = OpenAiConfig {
2238            codex_auth_path: Some(PathBuf::from("/tmp/codex/auth.json")),
2239            ..OpenAiConfig::default()
2240        };
2241        assert_eq!(
2242            explicit.resolve_auth_path(None).unwrap(),
2243            PathBuf::from("/tmp/codex/auth.json")
2244        );
2245
2246        let bare = OpenAiConfig::default();
2247        assert_eq!(
2248            bare.resolve_auth_path(None).unwrap(),
2249            crate::openai::creds::default_path().unwrap(),
2250            "no codex_auth_path must still mean ~/.codex/auth.json"
2251        );
2252    }
2253
2254    /// Each named account resolves its own file, and the default login is still
2255    /// reachable alongside them.
2256    #[test]
2257    fn openai_named_accounts_resolve_their_own_auth_file() {
2258        let config: Config = toml::from_str(
2259            r#"
2260            [openai]
2261            codex_auth_path = "/tmp/personal/auth.json"
2262            [[openai.accounts]]
2263            label = "work"
2264            codex_auth_path = "/tmp/work/auth.json"
2265            "#,
2266        )
2267        .unwrap();
2268
2269        assert_eq!(
2270            config.openai.resolve_auth_path(Some("work")).unwrap(),
2271            PathBuf::from("/tmp/work/auth.json")
2272        );
2273        assert_eq!(
2274            config.openai.resolve_auth_path(None).unwrap(),
2275            PathBuf::from("/tmp/personal/auth.json")
2276        );
2277    }
2278
2279    /// An unknown label must fail rather than quietly fall back to the default
2280    /// login — reporting the wrong subscription's usage is worse than an error.
2281    #[test]
2282    fn an_unknown_openai_account_is_an_error_not_a_fallback() {
2283        let config = OpenAiConfig {
2284            codex_auth_path: Some(PathBuf::from("/tmp/personal/auth.json")),
2285            accounts: vec![OpenAiAccount {
2286                label: "work".into(),
2287                codex_auth_path: PathBuf::from("/tmp/work/auth.json"),
2288            }],
2289            ..OpenAiConfig::default()
2290        };
2291        let err = config
2292            .resolve_auth_path(Some("nope"))
2293            .unwrap_err()
2294            .to_string();
2295        assert!(err.contains("nope"), "{err}");
2296        assert!(err.contains("[[openai.accounts]]"), "{err}");
2297    }
2298
2299    #[test]
2300    fn defaults_enable_only_the_five_core_vendors() {
2301        let c = Config::default();
2302        assert!(c.is_enabled(VendorId::Anthropic));
2303        assert!(c.is_enabled(VendorId::Openai));
2304        assert!(c.is_enabled(VendorId::Zai));
2305        assert!(c.is_enabled(VendorId::Openrouter));
2306        assert!(c.is_enabled(VendorId::CommandCode));
2307        for opt_in in [
2308            VendorId::AnthropicApi,
2309            VendorId::Copilot,
2310            VendorId::Deepseek,
2311            VendorId::Kimi,
2312            VendorId::Kilo,
2313            VendorId::Novita,
2314            VendorId::Moonshot,
2315            VendorId::Grok,
2316            VendorId::Supergrok,
2317            VendorId::Grokbot,
2318            VendorId::Cursor,
2319            VendorId::Minimax,
2320            VendorId::Kiro,
2321        ] {
2322            assert!(!c.is_enabled(opt_in), "{opt_in:?}");
2323        }
2324        assert_eq!(c.enabled_vendors().len(), 5);
2325    }
2326
2327    #[test]
2328    fn new_provider_defaults_are_opt_in_and_use_exact_auth_contracts() {
2329        let config = Config::default();
2330        assert!(!config.is_enabled(VendorId::NousResearch));
2331        assert!(!config.is_enabled(VendorId::OpenCodeGo));
2332        assert_eq!(config.opencode_go.api_key_env, "OPENCODE_GO_API_KEY");
2333        assert!(config.opencode_go.api_key.is_none());
2334        assert!(!config.is_enabled(VendorId::Copilot));
2335    }
2336
2337    #[cfg(unix)]
2338    #[test]
2339    fn inline_credentials_are_protected() {
2340        let mut config = Config::default();
2341        config.opencode_go.api_key = Some("<redacted>".to_string());
2342        assert!(config.has_inline_secrets());
2343    }
2344
2345    #[test]
2346    fn antigravity_oauth_client_overrides_parse() {
2347        let config: Config = toml::from_str(
2348            "[antigravity]
2349enabled = true
2350oauth_client_id = \"test-client\"
2351oauth_client_secret = \"test-client-secret\"
2352",
2353        )
2354        .unwrap();
2355        assert!(config.antigravity.enabled);
2356        assert_eq!(
2357            config.antigravity.oauth_client_id.as_deref(),
2358            Some("test-client")
2359        );
2360        assert_eq!(
2361            config.antigravity.oauth_client_secret.as_deref(),
2362            Some("test-client-secret")
2363        );
2364        let bare: Config = toml::from_str(
2365            "[antigravity]
2366enabled = true
2367",
2368        )
2369        .unwrap();
2370        assert!(bare.antigravity.oauth_client_id.is_none());
2371        assert!(bare.antigravity.oauth_client_secret.is_none());
2372    }
2373
2374    #[cfg(unix)]
2375    #[test]
2376    fn antigravity_inline_oauth_secret_receives_config_file_protection() {
2377        let mut config = Config::default();
2378        config.antigravity.oauth_client_id = Some("test-client".into());
2379        assert!(!config.has_inline_secrets());
2380        config.antigravity.oauth_client_secret = Some("<redacted>".into());
2381        assert!(config.has_inline_secrets());
2382    }
2383
2384    #[cfg(unix)]
2385    #[test]
2386    fn openrouter_named_inline_keys_receive_config_file_protection() {
2387        let mut config = Config::default();
2388        config.openrouter.accounts.push(OpenRouterAccount {
2389            label: "work".into(),
2390            api_key_env: None,
2391            api_key: Some("<redacted>".into()),
2392        });
2393        assert!(config.has_inline_secrets());
2394    }
2395
2396    #[test]
2397    fn missing_file_uses_defaults() {
2398        let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
2399        let c = Config::load_from(path).unwrap();
2400        assert!(c.is_enabled(VendorId::Anthropic));
2401    }
2402
2403    #[test]
2404    fn parses_full_config() {
2405        let f = write_toml(
2406            r#"
2407            [anthropic]
2408            enabled = true
2409
2410            [openai]
2411            enabled = false
2412            admin_key_env = "MY_ADMIN_KEY"
2413
2414            [zai]
2415            enabled = true
2416            api_key_env = "MY_ZAI"
2417            plan_tier = "pro"
2418
2419            [openrouter]
2420            enabled = false
2421            "#,
2422        );
2423        let c = Config::load_from(f.path()).unwrap();
2424        assert!(c.is_enabled(VendorId::Anthropic));
2425        assert!(!c.is_enabled(VendorId::Openai));
2426        assert!(c.is_enabled(VendorId::Zai));
2427        assert!(!c.is_enabled(VendorId::Openrouter));
2428        assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
2429        assert_eq!(c.zai.api_key_env, "MY_ZAI");
2430        assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
2431        assert!(c.openrouter.accounts.is_empty());
2432        assert!(c.openrouter.show_default_account);
2433    }
2434
2435    #[test]
2436    fn partial_config_falls_back_to_defaults() {
2437        let f = write_toml(
2438            r#"[openai]
2439enabled = false
2440"#,
2441        );
2442        let c = Config::load_from(f.path()).unwrap();
2443        assert!(!c.is_enabled(VendorId::Openai));
2444        // Other vendors keep their defaults.
2445        assert!(c.is_enabled(VendorId::Anthropic));
2446        assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
2447    }
2448
2449    #[test]
2450    fn malformed_toml_returns_error() {
2451        let f = write_toml("this is not = = valid");
2452        assert!(Config::load_from(f.path()).is_err());
2453    }
2454
2455    #[cfg(unix)]
2456    #[test]
2457    fn load_from_tightens_world_readable_config_with_inline_api_key() {
2458        let file = write_toml("[zai]\napi_key = \"test-inline-key\"\n");
2459        std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
2460
2461        Config::load_from(file.path()).unwrap();
2462
2463        assert_eq!(
2464            std::fs::metadata(file.path()).unwrap().mode() & 0o777,
2465            0o600
2466        );
2467    }
2468
2469    #[cfg(unix)]
2470    #[test]
2471    fn load_from_leaves_world_readable_config_without_inline_api_keys_unchanged() {
2472        let file = write_toml("[zai]\napi_key_env = \"TEST_ZAI_API_KEY\"\n");
2473        std::fs::set_permissions(file.path(), std::fs::Permissions::from_mode(0o644)).unwrap();
2474
2475        Config::load_from(file.path()).unwrap();
2476
2477        assert_eq!(
2478            std::fs::metadata(file.path()).unwrap().mode() & 0o777,
2479            0o644
2480        );
2481    }
2482
2483    #[cfg(unix)]
2484    #[test]
2485    fn inline_key_permission_decision_requires_tightening_for_group_or_other_bits() {
2486        assert_eq!(
2487            inline_key_permission_decision(0o600),
2488            InlineKeyPermissionDecision::Ok
2489        );
2490        assert_eq!(
2491            inline_key_permission_decision(0o640),
2492            InlineKeyPermissionDecision::Tighten
2493        );
2494        assert_eq!(
2495            inline_key_permission_decision(0o604),
2496            InlineKeyPermissionDecision::Tighten
2497        );
2498    }
2499
2500    #[test]
2501    fn anthropic_api_monthly_limit_must_be_positive_and_finite() {
2502        for value in ["0", "-1", "inf", "nan"] {
2503            let file = write_toml(&format!("[anthropic_api]\nmonthly_limit = {value}\n"));
2504            let error = Config::load_from(file.path()).unwrap_err().to_string();
2505            assert!(error.contains("monthly_limit"), "value {value}: {error}");
2506        }
2507
2508        let file = write_toml("[anthropic_api]\nmonthly_limit = 1000\n");
2509        assert_eq!(
2510            Config::load_from(file.path())
2511                .unwrap()
2512                .anthropic_api
2513                .monthly_limit,
2514            Some(1000.0)
2515        );
2516    }
2517
2518    #[test]
2519    fn minimax_region_accepts_only_known_instances() {
2520        for region in ["global", "GLOBAL", "cn", "CN"] {
2521            let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
2522            assert_eq!(
2523                Config::load_from(file.path()).unwrap().minimax.region,
2524                region
2525            );
2526        }
2527
2528        for region in ["", "china", "us"] {
2529            let file = write_toml(&format!("[minimax]\nregion = {region:?}\n"));
2530            let error = Config::load_from(file.path()).unwrap_err().to_string();
2531            assert!(error.contains("[minimax] region"), "{error}");
2532        }
2533    }
2534
2535    #[test]
2536    fn kimi_region_accepts_auto_and_both_deployments() {
2537        for region in ["auto", "AUTO", "cn", "mainland-cn", "global"] {
2538            let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
2539            assert_eq!(Config::load_from(file.path()).unwrap().kimi.region, region);
2540        }
2541
2542        for region in ["", "us", "oversea"] {
2543            let file = write_toml(&format!("[kimi]\nregion = {region:?}\n"));
2544            let error = Config::load_from(file.path()).unwrap_err().to_string();
2545            assert!(error.contains("[kimi] region"), "{error}");
2546        }
2547    }
2548
2549    #[test]
2550    fn kimi_defaults_to_auto_region_and_no_credential_override() {
2551        let defaults = KimiConfig::default();
2552        assert_eq!(defaults.region, "auto");
2553        assert_eq!(defaults.credentials_path, None);
2554        assert!(!defaults.enabled);
2555    }
2556
2557    #[test]
2558    fn kimi_credentials_path_expands_a_tilde() {
2559        let file = write_toml("[kimi]\ncredentials_path = \"~/kimi/creds.json\"\n");
2560        let path = Config::load_from(file.path())
2561            .unwrap()
2562            .kimi
2563            .credentials_path
2564            .unwrap();
2565        assert!(!path.starts_with("~"), "{}", path.display());
2566        assert!(path.ends_with("kimi/creds.json"), "{}", path.display());
2567    }
2568
2569    #[test]
2570    fn grokbot_is_opt_in_and_takes_no_api_key() {
2571        let defaults = GrokbotConfig::default();
2572        assert!(!defaults.enabled);
2573        assert_eq!(defaults.secrets_path, None);
2574        // No key surface of any kind: the app's own session is the login.
2575        let config = Config::default();
2576        assert_eq!(config.api_key_env_for(VendorId::Grokbot), "");
2577        assert_eq!(config.inline_api_key(VendorId::Grokbot), None);
2578
2579        let file = write_toml("[grokbot]\nenabled = true\n");
2580        let config = Config::load_from(file.path()).unwrap();
2581        assert!(config.is_enabled(VendorId::Grokbot));
2582        assert!(config.enabled_vendors().contains(&VendorId::Grokbot));
2583    }
2584
2585    #[test]
2586    fn grokbot_secrets_path_expands_a_tilde() {
2587        let file = write_toml("[grokbot]\nsecrets_path = \"~/gb/secrets.json\"\n");
2588        let path = Config::load_from(file.path())
2589            .unwrap()
2590            .grokbot
2591            .secrets_path
2592            .unwrap();
2593        assert!(!path.starts_with("~"), "{}", path.display());
2594        assert!(path.ends_with("gb/secrets.json"), "{}", path.display());
2595    }
2596
2597    #[test]
2598    fn optional_api_key_reports_absence_instead_of_failing() {
2599        assert_eq!(
2600            optional_api_key("KIMI_API_KEY_DEFINITELY_UNSET", Some("inline")),
2601            Some("inline".to_string())
2602        );
2603        assert_eq!(
2604            optional_api_key("KIMI_API_KEY_DEFINITELY_UNSET", None),
2605            None
2606        );
2607        assert_eq!(optional_api_key("KIMI_API_KEY_UNSET", Some("")), None);
2608        // An unusable `api_key_env` still lets an inline key through, exactly
2609        // as `resolve_api_key` does.
2610        assert_eq!(
2611            optional_api_key("9INVALID", Some("inline")),
2612            Some("inline".to_string())
2613        );
2614    }
2615
2616    #[test]
2617    fn context_monitor_is_opt_in_and_window_sizes_are_explicit() {
2618        let defaults = Config::default();
2619        assert!(!defaults.context.enabled);
2620        assert_eq!(
2621            defaults.context.window_tokens_for(Some("claude-test")),
2622            None
2623        );
2624
2625        let file = write_toml(
2626            r#"
2627            [context]
2628            enabled = true
2629            context_window_tokens = 200000
2630
2631            [context.model_context_window_tokens]
2632            claude-opus-1m = 1000000
2633            "claude exact id" = 300000
2634            "#,
2635        );
2636        let config = Config::load_from(file.path()).unwrap();
2637        assert!(config.context.enabled);
2638        assert_eq!(
2639            config.context.window_tokens_for(Some("claude-opus-1m")),
2640            Some(1_000_000)
2641        );
2642        assert_eq!(
2643            config.context.window_tokens_for(Some("claude exact id")),
2644            Some(300_000)
2645        );
2646        assert_eq!(
2647            config.context.window_tokens_for(Some("another-model")),
2648            Some(200_000)
2649        );
2650    }
2651
2652    #[test]
2653    fn context_layout_defaults_to_full_and_parses_each_variant() {
2654        assert_eq!(Config::default().context.layout, ContextLayout::Full);
2655        for (text, want) in [
2656            ("full", ContextLayout::Full),
2657            ("split", ContextLayout::Split),
2658            ("bottom", ContextLayout::Bottom),
2659        ] {
2660            let file = write_toml(&format!("[context]\nlayout = \"{text}\"\n"));
2661            assert_eq!(Config::load_from(file.path()).unwrap().context.layout, want);
2662        }
2663        let file = write_toml("[context]\nlayout = \"floating\"\n");
2664        assert!(
2665            Config::load_from(file.path()).is_err(),
2666            "an unknown layout must be rejected, not silently defaulted"
2667        );
2668    }
2669
2670    #[test]
2671    fn vendor_box_defaults_to_sidebar_and_parses_each_variant() {
2672        assert_eq!(Config::default().ui.vendor_box(), VendorBoxStyle::Sidebar);
2673        for (text, want) in [
2674            ("sidebar", VendorBoxStyle::Sidebar),
2675            ("navbar", VendorBoxStyle::Navbar),
2676            ("none", VendorBoxStyle::None),
2677        ] {
2678            let file = write_toml(&format!("[ui]\nvendor_box = \"{text}\"\n"));
2679            assert_eq!(
2680                Config::load_from(file.path()).unwrap().ui.vendor_box(),
2681                want
2682            );
2683        }
2684        let file = write_toml("[ui]\nvendor_box = \"floating\"\n");
2685        assert!(
2686            Config::load_from(file.path()).is_err(),
2687            "an unknown vendor_box style must be rejected, not silently defaulted"
2688        );
2689    }
2690
2691    #[test]
2692    fn context_window_sizes_must_be_nonzero_and_model_ids_nonempty() {
2693        for source in [
2694            "[context]\ncontext_window_tokens = 0\n",
2695            "[context.model_context_window_tokens]\nclaude = 0\n",
2696            "[context.model_context_window_tokens]\n\" \" = 200000\n",
2697        ] {
2698            let file = write_toml(source);
2699            let error = Config::load_from(file.path()).unwrap_err().to_string();
2700            assert!(error.contains("context"), "{error}");
2701        }
2702    }
2703
2704    // serial guard for env-var manipulation tests so they don't race
2705    fn env_guard() -> std::sync::MutexGuard<'static, ()> {
2706        static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
2707        M.lock().unwrap_or_else(|p| p.into_inner())
2708    }
2709
2710    #[test]
2711    fn resolve_api_key_prefers_env_over_inline() {
2712        let _g = env_guard();
2713        // Use a unique env var name so we don't clobber test parallelism.
2714        let var = "AI_USAGEBAR_TEST_ENV_WINS";
2715        // SAFETY: tests are single-threaded under env_guard.
2716        unsafe { std::env::set_var(var, "from-env") };
2717        let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
2718        unsafe { std::env::remove_var(var) };
2719        assert_eq!(got, "from-env");
2720    }
2721
2722    #[test]
2723    fn resolve_api_key_falls_back_to_inline() {
2724        let _g = env_guard();
2725        let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
2726        unsafe { std::env::remove_var(var) };
2727        let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
2728        assert_eq!(got, "inline-key");
2729    }
2730
2731    #[test]
2732    fn copilot_token_prefers_explicit_environment_over_gh_cli() {
2733        struct NeverRun;
2734        impl crate::copilot::credentials::GhAuthTokenRunner for NeverRun {
2735            fn run(
2736                &self,
2737                _: &crate::copilot::credentials::GhAuthTokenCommand,
2738            ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
2739                panic!("environment override must not invoke gh")
2740            }
2741        }
2742
2743        let token = CopilotConfig::default()
2744            .resolve_token_with(
2745                |name| (name == "GITHUB_COPILOT_TOKEN").then(|| "from-environment".into()),
2746                &NeverRun,
2747            )
2748            .unwrap();
2749        assert_eq!(token, "from-environment");
2750    }
2751
2752    #[test]
2753    fn copilot_token_uses_injected_gh_cli_and_hides_failure_output() {
2754        struct FailedGh;
2755        impl crate::copilot::credentials::GhAuthTokenRunner for FailedGh {
2756            fn run(
2757                &self,
2758                _: &crate::copilot::credentials::GhAuthTokenCommand,
2759            ) -> std::io::Result<crate::copilot::credentials::GhAuthTokenOutput> {
2760                Ok(crate::copilot::credentials::GhAuthTokenOutput {
2761                    success: false,
2762                    stdout: b"never-echo-gh-output".to_vec(),
2763                })
2764            }
2765        }
2766        let error = CopilotConfig::default()
2767            .resolve_token_with(|_| None, &FailedGh)
2768            .unwrap_err()
2769            .to_string();
2770        assert!(error.contains("gh auth login --web"));
2771        assert!(!error.contains("never-echo-gh-output"));
2772    }
2773
2774    #[test]
2775    fn resolve_api_key_errors_when_both_missing() {
2776        let _g = env_guard();
2777        let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
2778        unsafe { std::env::remove_var(var) };
2779        let err = resolve_api_key("Zai", var, None).unwrap_err();
2780        match err {
2781            crate::error::AppError::Credentials(msg) => {
2782                assert!(
2783                    msg.contains("api_key"),
2784                    "error should suggest config field: {msg}"
2785                );
2786            }
2787            other => panic!("expected Credentials error, got {other:?}"),
2788        }
2789    }
2790
2791    #[test]
2792    fn resolve_api_key_uses_exact_opencode_go_section_name() {
2793        let _g = env_guard();
2794        unsafe { std::env::remove_var("OPENCODE_GO_API_KEY") };
2795        let err = resolve_api_key("OpenCode Go", "OPENCODE_GO_API_KEY", None).unwrap_err();
2796        let message = err.to_string();
2797        assert!(
2798            message.contains("[opencode-go]"),
2799            "wrong section hint: {message}"
2800        );
2801        assert!(
2802            !message.contains("[opencode go]"),
2803            "wrong section hint: {message}"
2804        );
2805    }
2806
2807    fn path_override_guard() -> std::sync::MutexGuard<'static, ()> {
2808        static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
2809        M.lock().unwrap_or_else(|p| p.into_inner())
2810    }
2811
2812    /// Serializes the override tests *and* guarantees the process-wide
2813    /// override is dropped when the test ends — including via a panic, which
2814    /// a bare set/clear pair does not survive. A leaked override makes every
2815    /// later test in this process resolve a deleted temp file, turning one
2816    /// failure into a cascade of confusing sibling failures.
2817    struct ScopedPathOverride {
2818        _serial: std::sync::MutexGuard<'static, ()>,
2819    }
2820
2821    impl Drop for ScopedPathOverride {
2822        fn drop(&mut self) {
2823            clear_override_path();
2824        }
2825    }
2826
2827    fn scoped_path_override() -> ScopedPathOverride {
2828        ScopedPathOverride {
2829            _serial: path_override_guard(),
2830        }
2831    }
2832
2833    #[test]
2834    fn override_path_wins_over_canonical_and_legacy() {
2835        let _scoped = scoped_path_override();
2836        let file = NamedTempFile::new().unwrap();
2837        set_override_path(file.path());
2838        assert_eq!(resolved_path().as_deref(), Some(file.path()));
2839        assert_eq!(config_path_hint(), file.path().display().to_string());
2840        clear_override_path();
2841        // The usual locations decide again once the override is gone.
2842        let p = resolved_path().expect("a config path must resolve");
2843        assert!(p.ends_with("config.toml"));
2844    }
2845
2846    #[test]
2847    fn scoped_override_guard_clears_the_override_on_panic() {
2848        // Silence the simulated failure's hook output; the assertion below is
2849        // the real report.
2850        let hook = std::panic::take_hook();
2851        std::panic::set_hook(Box::new(|_| {}));
2852        let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2853            let _scoped = scoped_path_override();
2854            set_override_path(std::path::Path::new("panicked-override.toml"));
2855            panic!("simulated mid-test failure");
2856        }))
2857        .is_err();
2858        std::panic::set_hook(hook);
2859        assert!(panicked, "the simulated failure must run");
2860        let _serial = path_override_guard();
2861        assert!(
2862            override_path().is_none(),
2863            "a panicking test must not leak the override into siblings"
2864        );
2865    }
2866
2867    #[test]
2868    fn config_path_hint_ends_with_config_toml() {
2869        let _g = path_override_guard();
2870        // Platform-resolved (Linux/macOS/Windows), but always ends in the
2871        // config filename — the trailing segment is what messages rely on.
2872        assert!(config_path_hint().ends_with("config.toml"));
2873    }
2874
2875    #[test]
2876    fn config_flag_value_splits_the_equals_form() {
2877        use std::ffi::OsStr;
2878        assert_eq!(
2879            config_flag_value(OsStr::new("--config=work.toml")).as_deref(),
2880            Some(std::path::Path::new("work.toml"))
2881        );
2882        assert_eq!(
2883            config_flag_value(OsStr::new("--config=")).as_deref(),
2884            Some(std::path::Path::new(""))
2885        );
2886        assert_eq!(config_flag_value(OsStr::new("--config")), None);
2887        assert_eq!(config_flag_value(OsStr::new("--config-file")), None);
2888        assert_eq!(config_flag_value(OsStr::new("account")), None);
2889    }
2890
2891    /// The `--config=PATH` form must preserve a path the platform can store
2892    /// but UTF-8 cannot represent — `to_string_lossy` would replace the bad
2893    /// bytes with U+FFFD and produce a false "config file not found".
2894    #[cfg(unix)]
2895    #[test]
2896    fn config_flag_value_keeps_undecodable_bytes_intact() {
2897        use std::ffi::OsString;
2898        use std::os::unix::ffi::{OsStrExt, OsStringExt};
2899        let raw = OsString::from_vec(b"--config=caf\xe9.toml".to_vec());
2900        let value = config_flag_value(&raw).expect("prefix matches");
2901        assert_eq!(value.as_os_str().as_bytes(), b"caf\xe9.toml");
2902    }
2903
2904    #[cfg(windows)]
2905    #[test]
2906    fn config_flag_value_keeps_lone_surrogates_intact() {
2907        use std::ffi::OsString;
2908        use std::os::windows::ffi::{OsStrExt, OsStringExt};
2909        let mut wide: Vec<u16> = "--config=".encode_utf16().collect();
2910        wide.push(0xDC00); // lone low surrogate: not valid Unicode
2911        wide.extend("x.toml".encode_utf16());
2912        let raw = OsString::from_wide(&wide);
2913        let value = config_flag_value(&raw).expect("prefix matches");
2914        let mut expected = vec![0xDC00u16];
2915        expected.extend("x.toml".encode_utf16());
2916        assert_eq!(
2917            value.as_os_str().encode_wide().collect::<Vec<_>>(),
2918            expected
2919        );
2920    }
2921
2922    #[test]
2923    fn resolve_api_key_treats_empty_env_as_unset() {
2924        let _g = env_guard();
2925        let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
2926        unsafe { std::env::set_var(var, "") };
2927        let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
2928        unsafe { std::env::remove_var(var) };
2929        assert_eq!(got, "inline");
2930    }
2931
2932    #[test]
2933    fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
2934        let _g = env_guard();
2935        // Simulates a user accidentally pasting the key into api_key_env.
2936        let bad = "sk-kimi-very-real-looking-pasted-secret";
2937        let err = resolve_api_key("Kimi", bad, None).unwrap_err();
2938        let msg = err.to_string();
2939        assert!(
2940            msg.contains("invalid") && msg.contains("api_key_env"),
2941            "error should explain misconfiguration: {msg}"
2942        );
2943        assert!(
2944            !msg.contains(bad),
2945            "error must not echo the misconfigured value: {msg}"
2946        );
2947        assert!(msg.contains("valid environment variable name"));
2948        assert!(
2949            msg.contains("[kimi]"),
2950            "error should point at the lowercase TOML section: {msg}"
2951        );
2952    }
2953
2954    #[test]
2955    fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
2956        let _g = env_guard();
2957        let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
2958        assert_eq!(got, "inline-key");
2959    }
2960
2961    #[test]
2962    fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
2963        let _g = env_guard();
2964        // This is syntactically a valid environment variable name, but could
2965        // be a pasted secret and must not be reflected in the error.
2966        let pasted_secret = "sk_pasted_secret";
2967        unsafe { std::env::remove_var(pasted_secret) };
2968        let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
2969        assert!(
2970            !err.to_string().contains(pasted_secret),
2971            "error must not echo configured api_key_env values"
2972        );
2973    }
2974
2975    #[test]
2976    fn is_valid_env_var_name_rules() {
2977        // Valid: alphabetic or underscore first, then alnum/underscore.
2978        for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
2979            assert!(is_valid_env_var_name(valid), "{valid} should be valid");
2980        }
2981        // Invalid: empty, digit-first, or shell-illegal characters.
2982        for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
2983            assert!(
2984                !is_valid_env_var_name(invalid),
2985                "{invalid} should be invalid"
2986            );
2987        }
2988    }
2989
2990    #[test]
2991    fn config_parses_with_inline_api_key_and_primary() {
2992        let f = write_toml(
2993            r#"
2994            [ui]
2995            primary = "openrouter"
2996
2997            [zai]
2998            enabled = true
2999            api_key_env = "MY_ZAI"
3000            api_key = "sk-zai-inline"
3001
3002            [openrouter]
3003            enabled = true
3004            api_key = "sk-or-inline"
3005            "#,
3006        );
3007        let c = Config::load_from(f.path()).unwrap();
3008        assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
3009        assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
3010        assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
3011    }
3012
3013    #[test]
3014    fn openrouter_named_accounts_preserve_the_default_contract() {
3015        let f = write_toml(
3016            r#"
3017            [openrouter]
3018            enabled = true
3019            api_key_env = "AI_USAGEBAR_TEST_OR_DEFAULT"
3020            api_key = "default-inline"
3021            show_default_account = false
3022
3023            [[openrouter.accounts]]
3024            label = "work"
3025            api_key_env = "OPENROUTER_WORK_API_KEY"
3026
3027            [[openrouter.accounts]]
3028            label = "personal"
3029            api_key = "personal-inline"
3030            "#,
3031        );
3032        let _g = env_guard();
3033        unsafe { std::env::remove_var("AI_USAGEBAR_TEST_OR_DEFAULT") };
3034        let config = Config::load_from(f.path()).unwrap();
3035        assert!(!config.openrouter.show_default_account);
3036        assert_eq!(config.openrouter.accounts.len(), 2);
3037        assert_eq!(
3038            config.openrouter.resolve_api_key(None).unwrap(),
3039            "default-inline"
3040        );
3041        assert_eq!(
3042            config.openrouter.resolve_api_key(Some("personal")).unwrap(),
3043            "personal-inline"
3044        );
3045    }
3046
3047    #[test]
3048    fn openrouter_named_accounts_reject_ambiguous_or_unsafe_labels() {
3049        for source in [
3050            r#"
3051            [[openrouter.accounts]]
3052            label = "work"
3053            api_key = "one"
3054            [[openrouter.accounts]]
3055            label = "work"
3056            api_key = "two"
3057            "#,
3058            r#"
3059            [[openrouter.accounts]]
3060            label = "../work"
3061            api_key = "one"
3062            "#,
3063            r#"
3064            [[openrouter.accounts]]
3065            label = "work"
3066            "#,
3067        ] {
3068            let f = write_toml(source);
3069            assert!(Config::load_from(f.path()).is_err(), "accepted {source}");
3070        }
3071    }
3072
3073    #[test]
3074    fn openrouter_unknown_account_never_falls_back_to_default_key() {
3075        let mut config = OpenRouterConfig {
3076            api_key: Some("default-secret".into()),
3077            ..OpenRouterConfig::default()
3078        };
3079        config.accounts.push(OpenRouterAccount {
3080            label: "work".into(),
3081            api_key_env: None,
3082            api_key: Some("work-secret".into()),
3083        });
3084        let message = config
3085            .resolve_api_key(Some("missing"))
3086            .unwrap_err()
3087            .to_string();
3088        assert!(message.contains("missing") && message.contains("work"));
3089        assert!(!message.contains("default-secret"));
3090        assert!(!message.contains("work-secret"));
3091    }
3092
3093    #[test]
3094    fn openrouter_account_key_errors_do_not_echo_configured_values() {
3095        let config = OpenRouterConfig {
3096            accounts: vec![OpenRouterAccount {
3097                label: "work".into(),
3098                api_key_env: Some("sk_pasted_secret".into()),
3099                api_key: None,
3100            }],
3101            ..OpenRouterConfig::default()
3102        };
3103        let _g = env_guard();
3104        unsafe { std::env::remove_var("sk_pasted_secret") };
3105        let message = config
3106            .resolve_api_key(Some("work"))
3107            .unwrap_err()
3108            .to_string();
3109        assert!(message.contains("[[openrouter.accounts]]"));
3110        assert!(!message.contains("sk_pasted_secret"));
3111    }
3112
3113    #[test]
3114    fn enabled_vendors_preserves_canonical_order() {
3115        // DeepSeek and Kimi are disabled by default (require explicit API key
3116        // config), so they are absent from the enabled list unless enabled.
3117        let c = Config::default();
3118        assert_eq!(
3119            c.enabled_vendors(),
3120            vec![
3121                VendorId::Anthropic,
3122                VendorId::Openai,
3123                VendorId::Zai,
3124                VendorId::Openrouter,
3125                VendorId::CommandCode,
3126            ]
3127        );
3128    }
3129
3130    #[test]
3131    fn deepseek_appears_when_enabled() {
3132        let f = write_toml(
3133            r#"
3134            [deepseek]
3135            enabled = true
3136            api_key = "sk-test"
3137            "#,
3138        );
3139        let c = Config::load_from(f.path()).unwrap();
3140        assert!(c.is_enabled(VendorId::Deepseek));
3141        assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
3142        assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
3143    }
3144
3145    #[test]
3146    fn tilde_paths_are_expanded_on_load() {
3147        // `PathBuf` keeps `~` literally, so the documented
3148        // `credentials_path = "~/..."` used to resolve to a directory named
3149        // `~` relative to the process's cwd.
3150        let f = write_toml(
3151            r#"
3152            [context]
3153            projects_path = "~/.claude/projects"
3154
3155            [anthropic]
3156            credentials_path = "~/.claude/.credentials.json"
3157
3158            [[anthropic.accounts]]
3159            label = "work"
3160            credentials_path = "~/work.json"
3161            "#,
3162        );
3163        let c = Config::load_from(f.path()).unwrap();
3164        let home = crate::cache::home_dir().unwrap();
3165
3166        assert_eq!(c.context.projects_path, Some(home.join(".claude/projects")));
3167        let got = c.anthropic.credentials_path.unwrap();
3168        assert_eq!(got, home.join(".claude/.credentials.json"));
3169        assert!(!got.to_string_lossy().contains('~'));
3170        assert_eq!(
3171            c.anthropic.accounts[0].credentials_path,
3172            home.join("work.json")
3173        );
3174    }
3175
3176    #[test]
3177    fn absolute_and_relative_paths_are_left_alone() {
3178        let f = write_toml(
3179            r#"
3180            [anthropic]
3181            credentials_path = "/etc/creds.json"
3182            "#,
3183        );
3184        let c = Config::load_from(f.path()).unwrap();
3185        assert_eq!(
3186            c.anthropic.credentials_path.unwrap(),
3187            std::path::Path::new("/etc/creds.json")
3188        );
3189
3190        // `~user` is not ours to interpret.
3191        let f2 = write_toml(
3192            r#"
3193            [anthropic]
3194            credentials_path = "~someone/creds.json"
3195            "#,
3196        );
3197        let c2 = Config::load_from(f2.path()).unwrap();
3198        assert_eq!(
3199            c2.anthropic.credentials_path.unwrap(),
3200            std::path::Path::new("~someone/creds.json")
3201        );
3202    }
3203
3204    #[test]
3205    fn resolved_path_is_the_canonical_one_and_names_the_config_file() {
3206        let _g = path_override_guard();
3207        // Hermetic: only asserts the shape, never which file happens to exist
3208        // on the machine running the tests.
3209        let p = resolved_path().expect("a config path must resolve");
3210        assert!(p.ends_with("config.toml"));
3211        let canonical = default_path().unwrap();
3212        let legacy = legacy_xdg_path().unwrap();
3213        assert!(
3214            p == canonical || p == legacy,
3215            "resolved to an unexpected location: {}",
3216            p.display()
3217        );
3218    }
3219
3220    #[test]
3221    fn misspelled_section_is_rejected_not_ignored() {
3222        // The regression this guards: `[openrouer]` used to parse fine, leave
3223        // OpenRouter on its defaults, and give the user no hint at all.
3224        let f = write_toml(
3225            r#"
3226            [openrouer]
3227            enabled = true
3228            api_key = "sk-or-v1-typo"
3229            "#,
3230        );
3231        let err = Config::load_from(f.path()).unwrap_err().to_string();
3232        assert!(
3233            err.contains("openrouer"),
3234            "error should name the typo: {err}"
3235        );
3236    }
3237
3238    #[test]
3239    fn invalid_toml_is_an_error_not_silent_defaults() {
3240        let f = write_toml("[zai\nenabled = true\n");
3241        assert!(Config::load_from(f.path()).is_err());
3242    }
3243
3244    #[test]
3245    fn a_missing_file_is_still_just_defaults() {
3246        // Absence stays the legitimate "use defaults" case — only real parse
3247        // and I/O failures are errors.
3248        let dir = tempfile::tempdir().unwrap();
3249        let missing = dir.path().join("nope").join("config.toml");
3250        let c = Config::load_from(&missing).unwrap();
3251        assert!(c.is_enabled(VendorId::Anthropic));
3252    }
3253
3254    #[test]
3255    fn kimi_appears_when_enabled() {
3256        let f = write_toml(
3257            r#"
3258            [kimi]
3259            enabled = true
3260            api_key = "sk-test"
3261            "#,
3262        );
3263        let c = Config::load_from(f.path()).unwrap();
3264        assert!(c.is_enabled(VendorId::Kimi));
3265        assert!(c.enabled_vendors().contains(&VendorId::Kimi));
3266        assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
3267    }
3268
3269    #[test]
3270    fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
3271        let f = write_toml(
3272            r#"
3273            [deepseek]
3274            enabled = true
3275            api_key = "sk-ds"
3276
3277            [kimi]
3278            enabled = true
3279            api_key = "sk-kimi"
3280            "#,
3281        );
3282        let c = Config::load_from(f.path()).unwrap();
3283        assert_eq!(
3284            c.enabled_vendors(),
3285            vec![
3286                VendorId::Anthropic,
3287                VendorId::Openai,
3288                VendorId::Zai,
3289                VendorId::Openrouter,
3290                VendorId::Deepseek,
3291                VendorId::Kimi,
3292                VendorId::CommandCode,
3293            ]
3294        );
3295    }
3296
3297    #[test]
3298    fn parses_anthropic_accounts_and_looks_them_up() {
3299        let f = write_toml(
3300            r#"
3301            [anthropic]
3302            enabled = true
3303
3304            [[anthropic.accounts]]
3305            label = "personal"
3306            credentials_path = "/creds/personal.json"
3307
3308            [[anthropic.accounts]]
3309            label = "work"
3310            credentials_path = "/creds/work.json"
3311            "#,
3312        );
3313        let c = Config::load_from(f.path()).unwrap();
3314        assert_eq!(c.anthropic.accounts.len(), 2);
3315        let work = c.anthropic.account("work").unwrap();
3316        assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
3317        // A typo names the offending label and lists the known ones.
3318        let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
3319        assert!(err.contains("missing") && err.contains("work"), "{err}");
3320    }
3321
3322    #[test]
3323    fn duplicate_anthropic_account_labels_are_rejected_on_load() {
3324        let f = write_toml(
3325            r#"
3326            [[anthropic.accounts]]
3327            label = "work"
3328            credentials_path = "/creds/work-one.json"
3329
3330            [[anthropic.accounts]]
3331            label = "work"
3332            credentials_path = "/creds/work-two.json"
3333            "#,
3334        );
3335        let err = Config::load_from(f.path()).unwrap_err().to_string();
3336        assert!(
3337            err.contains("duplicate anthropic account label \"work\""),
3338            "{err}"
3339        );
3340    }
3341
3342    #[test]
3343    fn account_label_rejects_path_like_names() {
3344        let cfg = AnthropicConfig::default();
3345        for bad in [
3346            "",
3347            ".",
3348            "..",
3349            "a/b",
3350            r"a\b",
3351            "C:work",
3352            "line\nbreak",
3353            "tab\tname",
3354            "usage.json",
3355            ".stale",
3356            ".last_error",
3357            ".fetch.lock",
3358        ] {
3359            let err = cfg.account(bad).unwrap_err();
3360            assert!(
3361                format!("{err:?}").contains("invalid anthropic account label"),
3362                "{bad:?} should be rejected as a label"
3363            );
3364        }
3365    }
3366
3367    #[test]
3368    fn anthropic_accounts_default_to_empty() {
3369        // No [[anthropic.accounts]] → the single default account, empty list,
3370        // nothing to migrate (issue #14, back-compat rule 1).
3371        assert!(Config::default().anthropic.accounts.is_empty());
3372        assert!(Config::default().anthropic.accounts_dir.is_none());
3373    }
3374
3375    // --- accounts_dir: CLAUDE_CONFIG_DIR-style auto-discovery ----------------
3376    // All hermetic: discovery reads a TempDir, never the user's real config.
3377
3378    /// Create `<root>/<label>/.credentials.json` (contents irrelevant here —
3379    /// discovery keys on the file existing, the fetch path parses it).
3380    fn seed_account_dir(root: &std::path::Path, label: &str) {
3381        let dir = root.join(label);
3382        std::fs::create_dir_all(&dir).unwrap();
3383        std::fs::write(dir.join(".credentials.json"), "{}").unwrap();
3384    }
3385
3386    #[test]
3387    fn discovers_account_dirs_in_claude_config_dir_layout() {
3388        let td = tempfile::tempdir().unwrap();
3389        seed_account_dir(td.path(), "work");
3390        seed_account_dir(td.path(), "personal");
3391        // Keychain-backed macOS logins may not write .credentials.json; their
3392        // config directories are still account entries.
3393        std::fs::create_dir_all(td.path().join("keychain-only")).unwrap();
3394        // A loose file (not a dir) is ignored.
3395        std::fs::write(td.path().join("stray.json"), "{}").unwrap();
3396
3397        let cfg = AnthropicConfig {
3398            accounts_dir: Some(td.path().to_path_buf()),
3399            ..Default::default()
3400        };
3401        let all = cfg.all_accounts();
3402        let labels: Vec<&str> = all.iter().map(|a| a.label.as_str()).collect();
3403        assert_eq!(labels, vec!["keychain-only", "personal", "work"]);
3404        assert_eq!(
3405            all[2].credentials_path,
3406            td.path().join("work").join(".credentials.json")
3407        );
3408    }
3409
3410    #[test]
3411    fn explicit_account_wins_over_a_discovered_one_with_the_same_label() {
3412        let td = tempfile::tempdir().unwrap();
3413        seed_account_dir(td.path(), "work");
3414        let cfg = AnthropicConfig {
3415            accounts: vec![AnthropicAccount {
3416                label: "work".into(),
3417                credentials_path: "/explicit/work.json".into(),
3418            }],
3419            accounts_dir: Some(td.path().to_path_buf()),
3420            ..Default::default()
3421        };
3422        let all = cfg.all_accounts();
3423        assert_eq!(all.len(), 1, "no duplicate label");
3424        assert_eq!(
3425            all[0].credentials_path,
3426            std::path::Path::new("/explicit/work.json"),
3427            "explicit entry wins"
3428        );
3429        // A discovered account is still reachable through `account()`.
3430        seed_account_dir(td.path(), "other");
3431        assert_eq!(cfg.account("other").unwrap().label, "other");
3432    }
3433
3434    #[test]
3435    fn missing_accounts_dir_is_silently_empty_not_an_error() {
3436        let cfg = AnthropicConfig {
3437            accounts_dir: Some("/nonexistent/ai-usagebar-accounts".into()),
3438            ..Default::default()
3439        };
3440        assert!(cfg.all_accounts().is_empty());
3441    }
3442
3443    #[test]
3444    fn openai_account_auth_paths_are_tilde_expanded_on_load() {
3445        let f = write_toml(
3446            r#"
3447            [[openai.accounts]]
3448            label = "work"
3449            codex_auth_path = "~/.codex-work/auth.json"
3450            "#,
3451        );
3452        let c = Config::load_from(f.path()).unwrap();
3453        let home = crate::cache::home_dir().unwrap();
3454        assert_eq!(
3455            c.openai.accounts[0].codex_auth_path,
3456            home.join(".codex-work/auth.json")
3457        );
3458    }
3459
3460    #[test]
3461    fn accounts_dir_is_tilde_expanded_on_load() {
3462        let f = write_toml(
3463            r#"
3464            [anthropic]
3465            accounts_dir = "~/.config/ai-usagebar/accounts"
3466            "#,
3467        );
3468        let c = Config::load_from(f.path()).unwrap();
3469        let home = crate::cache::home_dir().unwrap();
3470        assert_eq!(
3471            c.anthropic.accounts_dir,
3472            Some(home.join(".config/ai-usagebar/accounts"))
3473        );
3474    }
3475
3476    #[test]
3477    fn desktop_profiles_dir_is_tilde_expanded_on_load() {
3478        let f = write_toml(
3479            r#"
3480            [anthropic]
3481            desktop_profiles_dir = "~/.claude-acc/profiles"
3482            "#,
3483        );
3484        let c = Config::load_from(f.path()).unwrap();
3485        let home = crate::cache::home_dir().unwrap();
3486        assert_eq!(
3487            c.anthropic.desktop_profiles_dir,
3488            Some(home.join(".claude-acc/profiles"))
3489        );
3490    }
3491
3492    #[test]
3493    fn the_live_cli_account_is_read_from_the_default_credential_slot() {
3494        let cfg = AnthropicConfig {
3495            accounts: vec![
3496                AnthropicAccount {
3497                    label: "work".into(),
3498                    credentials_path: "/tmp/accounts/work/.credentials.json".into(),
3499                },
3500                AnthropicAccount {
3501                    label: "personal".into(),
3502                    credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
3503                },
3504            ],
3505            ..Default::default()
3506        };
3507
3508        let (idle, idle_cache) = cfg.account_target_with("work", Some("personal")).unwrap();
3509        assert!(
3510            matches!(&idle, CredsTarget::Named { config_dir, .. }
3511                if config_dir == std::path::Path::new("/tmp/accounts/work")),
3512            "{idle:?}"
3513        );
3514
3515        // Same label, but it is the login `claude` itself is using: one lineage.
3516        let (live, live_cache) = cfg.account_target_with("work", Some("work")).unwrap();
3517        assert!(matches!(live, CredsTarget::Default(_)), "{live:?}");
3518
3519        // The cache must not move, or a switch would silently orphan the tab's
3520        // usage history and show "Loading…" until the next fetch.
3521        assert_eq!(idle_cache.dir(), live_cache.dir());
3522    }
3523
3524    #[test]
3525    fn the_live_cli_account_keeps_its_own_slot_while_that_file_is_there() {
3526        // Two CLAUDE_CONFIG_DIRs can hold the same account, and each keeps its
3527        // own live credential — `resolve_active_label` matches the account, not
3528        // the lineage. Reading the default slot then hands back a credential
3529        // the user never logs into.
3530        let cfg = AnthropicConfig {
3531            accounts: vec![AnthropicAccount {
3532                label: "personal".into(),
3533                credentials_path: "/tmp/accounts/personal/.credentials.json".into(),
3534            }],
3535            ..Default::default()
3536        };
3537
3538        let (present, _) = cfg
3539            .account_target_probing("personal", Some("personal"), |_| true)
3540            .unwrap();
3541        assert!(
3542            matches!(&present, CredsTarget::Named { path, .. }
3543                if path == Path::new("/tmp/accounts/personal/.credentials.json")),
3544            "{present:?}"
3545        );
3546
3547        // Emptied by `account switch`: the credential really did move.
3548        let (moved, _) = cfg
3549            .account_target_probing("personal", Some("personal"), |_| false)
3550            .unwrap();
3551        assert!(matches!(moved, CredsTarget::Default(_)), "{moved:?}");
3552    }
3553
3554    #[test]
3555    fn the_live_cli_accounts_own_file_is_probed_on_disk() {
3556        // `account_target_probing` proves the decision; only the entry point
3557        // proves that the shipping caller probes at all. Fails on main, where
3558        // the live label is routed to the default slot unconditionally.
3559        let creds = NamedTempFile::new().unwrap();
3560        let cfg = AnthropicConfig {
3561            accounts: vec![AnthropicAccount {
3562                label: "personal".into(),
3563                credentials_path: creds.path().to_path_buf(),
3564            }],
3565            ..Default::default()
3566        };
3567        let (target, _) = cfg
3568            .account_target_with("personal", Some("personal"))
3569            .unwrap();
3570        assert!(
3571            matches!(&target, CredsTarget::Named { path, .. } if path == creds.path()),
3572            "read {target:?} instead of the account's own file"
3573        );
3574    }
3575
3576    #[test]
3577    fn no_live_cli_account_keeps_every_account_on_its_own_slot() {
3578        let cfg = AnthropicConfig {
3579            accounts: vec![AnthropicAccount {
3580                label: "work".into(),
3581                credentials_path: "/tmp/accounts/work/.credentials.json".into(),
3582            }],
3583            ..Default::default()
3584        };
3585        let (target, _) = cfg.account_target_with("work", None).unwrap();
3586        assert!(matches!(target, CredsTarget::Named { .. }), "{target:?}");
3587    }
3588
3589    /// The shipped example, which `make install` puts in
3590    /// `share/ai-usagebar/config.example.toml`. Repo-relative, so this stays
3591    /// hermetic — it never touches the user's real config.
3592    fn config_example() -> PathBuf {
3593        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("config.example.toml")
3594    }
3595
3596    #[test]
3597    fn shipped_example_parses_as_a_real_config() {
3598        // The example is documentation users copy verbatim, but nothing used
3599        // to parse it — so a renamed section or field could rot there
3600        // unnoticed, and `deny_unknown_fields` would reject the copy on the
3601        // user's machine instead of in CI.
3602        let c = Config::load_from(&config_example()).unwrap();
3603        assert!(!c.context.enabled);
3604        assert!(c.is_enabled(VendorId::Anthropic));
3605        assert!(c.is_enabled(VendorId::Openai));
3606        assert!(!c.is_enabled(VendorId::AnthropicApi));
3607        assert!(!c.is_enabled(VendorId::Deepseek));
3608        assert!(!c.is_enabled(VendorId::Kimi));
3609        assert!(!c.is_enabled(VendorId::Kilo));
3610        assert!(!c.is_enabled(VendorId::Novita));
3611        assert!(!c.is_enabled(VendorId::Moonshot));
3612        assert!(!c.is_enabled(VendorId::Grok));
3613        assert!(!c.is_enabled(VendorId::Cursor));
3614        assert!(!c.is_enabled(VendorId::Minimax));
3615    }
3616
3617    #[test]
3618    fn shipped_example_does_not_advertise_admin_key_env_as_working() {
3619        // The regression: the example shipped an *uncommented*
3620        // `admin_key_env = "OPENAI_ADMIN_KEY"`, indistinguishable from a live
3621        // setting. Nothing reads it, so a user could set it, skip
3622        // `codex login`, and wait for usage that never arrives.
3623        let text = std::fs::read_to_string(config_example()).unwrap();
3624        let live: Vec<&str> = text
3625            .lines()
3626            .map(str::trim)
3627            .filter(|l| l.contains("admin_key_env") && !l.starts_with('#'))
3628            .collect();
3629        assert!(
3630            live.is_empty(),
3631            "admin_key_env must stay commented out while it is inert: {live:?}"
3632        );
3633        // Still documented, though — silently dropping it would leave users
3634        // who already set it with no explanation of why it does nothing.
3635        assert!(
3636            text.contains("admin_key_env") && text.contains("RESERVED"),
3637            "the example should keep describing admin_key_env as reserved"
3638        );
3639    }
3640
3641    #[test]
3642    fn admin_key_env_is_accepted_but_changes_nothing() {
3643        // The field survives because the API-key-only path is still intended.
3644        // What has to hold today is narrower: setting it loads without error
3645        // and moves nothing the code actually acts on.
3646        let f = write_toml(
3647            r#"
3648            [openai]
3649            admin_key_env = "SOME_ADMIN_KEY"
3650            "#,
3651        );
3652        let c = Config::load_from(f.path()).unwrap();
3653        assert_eq!(c.openai.admin_key_env, "SOME_ADMIN_KEY");
3654        // Nothing else moved: OpenAI still resolves through Codex OAuth only.
3655        let default = OpenAiConfig::default();
3656        assert_eq!(c.openai.enabled, default.enabled);
3657        assert_eq!(c.openai.codex_auth_path, default.codex_auth_path);
3658        assert_eq!(c.enabled_vendors(), Config::default().enabled_vendors());
3659    }
3660
3661    #[test]
3662    fn config_example_documents_every_vendor_without_secrets() {
3663        let raw = std::fs::read_to_string(config_example()).unwrap();
3664        let cfg = Config::load_from(&config_example()).unwrap();
3665        // Every vendor the binary can dispatch needs a documented section, or
3666        // users have no way to discover how to turn it on.
3667        for id in VendorId::all() {
3668            let section = id.slug();
3669            assert!(
3670                raw.contains(&format!("[{section}]")),
3671                "config.example.toml has no [{section}] section"
3672            );
3673        }
3674
3675        // The example must not ship anything enabled-by-key-only, and must not
3676        // carry a real secret.
3677        assert!(!cfg.anthropic_api.enabled && cfg.anthropic_api.api_key.is_none());
3678        assert!(!cfg.kilo.enabled && cfg.kilo.api_key.is_none());
3679        assert!(!cfg.novita.enabled && cfg.novita.api_key.is_none());
3680        assert!(!cfg.moonshot.enabled && cfg.moonshot.api_key.is_none());
3681        assert!(!cfg.grok.enabled && cfg.grok.api_key.is_none());
3682        assert!(!cfg.supergrok.enabled);
3683        assert_eq!(cfg.supergrok.grok_binary, default_grok_binary());
3684        assert_eq!(
3685            cfg.supergrok
3686                .grok_binary
3687                .file_name()
3688                .and_then(|p| p.to_str()),
3689            Some(if cfg!(windows) { "grok.exe" } else { "grok" })
3690        );
3691        assert!(cfg.supergrok.auth_path.is_none());
3692        assert!(cfg.supergrok.config_path.is_none());
3693        assert!(!cfg.cursor.enabled && cfg.cursor.db_path.is_none());
3694        assert!(!cfg.kiro.enabled && cfg.kiro.db_path.is_none());
3695    }
3696
3697    #[test]
3698    fn supergrok_binary_must_not_be_empty() {
3699        let file = write_toml(
3700            r#"
3701            [supergrok]
3702            enabled = true
3703            grok_binary = ""
3704            "#,
3705        );
3706        let error = Config::load_from(file.path()).unwrap_err().to_string();
3707        assert!(error.contains("grok_binary must not be empty"));
3708    }
3709
3710    #[test]
3711    fn supergrok_paths_are_tilde_expanded() {
3712        let file = write_toml(
3713            r#"
3714            [supergrok]
3715            grok_binary = "~/bin/grok"
3716            auth_path = "~/.grok/auth.json"
3717            config_path = "~/.grok/config.toml"
3718            "#,
3719        );
3720        let config = Config::load_from(file.path()).unwrap();
3721        let home = crate::cache::home_dir().unwrap();
3722        assert_eq!(config.supergrok.grok_binary, home.join("bin/grok"));
3723        assert_eq!(
3724            config.supergrok.auth_path,
3725            Some(home.join(".grok/auth.json"))
3726        );
3727        assert_eq!(
3728            config.supergrok.config_path,
3729            Some(home.join(".grok/config.toml"))
3730        );
3731    }
3732
3733    #[test]
3734    fn kiro_db_path_is_tilde_expanded() {
3735        let f = write_toml(
3736            r#"
3737            [kiro]
3738            db_path = "~/kiro-data.sqlite3"
3739            "#,
3740        );
3741        let c = Config::load_from(f.path()).unwrap();
3742        let home = crate::cache::home_dir().unwrap();
3743        assert_eq!(c.kiro.db_path, Some(home.join("kiro-data.sqlite3")));
3744    }
3745
3746    #[test]
3747    fn kiro_appears_when_enabled() {
3748        let f = write_toml(
3749            r#"
3750            [kiro]
3751            enabled = true
3752            "#,
3753        );
3754        let c = Config::load_from(f.path()).unwrap();
3755        assert!(c.is_enabled(VendorId::Kiro));
3756        assert!(c.enabled_vendors().contains(&VendorId::Kiro));
3757    }
3758
3759    #[test]
3760    fn cursor_db_path_is_tilde_expanded() {
3761        let f = write_toml(
3762            r#"
3763            [cursor]
3764            db_path = "~/cursor-state.vscdb"
3765            "#,
3766        );
3767        let c = Config::load_from(f.path()).unwrap();
3768        let home = crate::cache::home_dir().unwrap();
3769        assert_eq!(c.cursor.db_path, Some(home.join("cursor-state.vscdb")));
3770    }
3771
3772    #[test]
3773    fn cursor_agent_auth_path_is_tilde_expanded() {
3774        let f = write_toml(
3775            r#"
3776            [cursor]
3777            agent_auth_path = "~/cursor-agent-auth.json"
3778            "#,
3779        );
3780        let c = Config::load_from(f.path()).unwrap();
3781        let home = crate::cache::home_dir().unwrap();
3782        assert_eq!(
3783            c.cursor.agent_auth_path,
3784            Some(home.join("cursor-agent-auth.json"))
3785        );
3786    }
3787
3788    #[test]
3789    fn cursor_appears_when_enabled() {
3790        let f = write_toml(
3791            r#"
3792            [cursor]
3793            enabled = true
3794            "#,
3795        );
3796        let c = Config::load_from(f.path()).unwrap();
3797        assert!(c.is_enabled(VendorId::Cursor));
3798        assert!(c.enabled_vendors().contains(&VendorId::Cursor));
3799    }
3800
3801    #[test]
3802    fn add_account_appends_and_preserves_existing() {
3803        let mut doc: toml_edit::DocumentMut = r#"
3804# keep me
3805[anthropic]
3806enabled = true
3807
3808[[anthropic.accounts]]
3809label = "personal"
3810credentials_path = "~/.config/ai-usagebar/accounts/personal/.credentials.json"
3811"#
3812        .parse()
3813        .unwrap();
3814        add_anthropic_account_to_doc(
3815            &mut doc,
3816            "work",
3817            "~/.config/ai-usagebar/accounts/work/.credentials.json",
3818        )
3819        .unwrap();
3820        let rendered = doc.to_string();
3821        assert!(rendered.contains("# keep me"), "comment must survive");
3822        // Round-trips through the real loader with both accounts intact and ordered.
3823        let f = write_toml(&rendered);
3824        let c = Config::load_from(f.path()).unwrap();
3825        let labels: Vec<&str> = c
3826            .anthropic
3827            .accounts
3828            .iter()
3829            .map(|a| a.label.as_str())
3830            .collect();
3831        assert_eq!(labels, vec!["personal", "work"]);
3832    }
3833
3834    #[test]
3835    fn add_account_to_empty_doc_is_loadable() {
3836        let mut doc = toml_edit::DocumentMut::new();
3837        add_anthropic_account_to_doc(&mut doc, "solo", "~/x/.credentials.json").unwrap();
3838        let f = write_toml(&doc.to_string());
3839        let c = Config::load_from(f.path()).unwrap();
3840        assert_eq!(c.anthropic.accounts.len(), 1);
3841        assert_eq!(c.anthropic.accounts[0].label, "solo");
3842    }
3843
3844    #[test]
3845    fn add_account_rejects_duplicate_label() {
3846        let mut doc: toml_edit::DocumentMut = r#"
3847[[anthropic.accounts]]
3848label = "work"
3849credentials_path = "~/w/.credentials.json"
3850"#
3851        .parse()
3852        .unwrap();
3853        assert!(
3854            add_anthropic_account_to_doc(&mut doc, "work", "~/other/.credentials.json").is_err(),
3855            "a duplicate label must be rejected, not appended"
3856        );
3857    }
3858
3859    #[test]
3860    fn add_account_rejects_bad_label() {
3861        let mut doc = toml_edit::DocumentMut::new();
3862        assert!(add_anthropic_account_to_doc(&mut doc, "a/b", "~/x/.credentials.json").is_err());
3863        assert!(add_anthropic_account_to_doc(&mut doc, "", "~/x/.credentials.json").is_err());
3864    }
3865
3866    #[test]
3867    fn tildify_collapses_home_only() {
3868        let home = Path::new("/Users/me");
3869        assert_eq!(tildify(&home.join("a/b"), home), "~/a/b");
3870        assert_eq!(tildify(Path::new("/etc/hosts"), home), "/etc/hosts");
3871    }
3872
3873    #[test]
3874    fn default_account_credentials_path_nests_under_config_dir() {
3875        let cfg = Path::new("/home/u/.config/ai-usagebar/config.toml");
3876        assert_eq!(
3877            default_account_credentials_path(cfg, "work"),
3878            Path::new("/home/u/.config/ai-usagebar/accounts/work/.credentials.json"),
3879        );
3880    }
3881    // ----- [[custom]] providers -----
3882
3883    const CUSTOM_BLOCK: &str = r#"
3884[[custom]]
3885id = "mytool"
3886name = "My Tool"
3887short_name = "myt"
3888enabled = true
3889url = "https://api.example.test/v1/usage"
3890api_key_env = "MYTOOL_API_KEY"
3891auth_header = "Authorization"
3892auth_scheme = "Bearer"
3893plan = "Pro"
3894cache_ttl_secs = 120
3895[custom.headers]
3896X-Org = "org_1"
3897[[custom.metrics]]
3898label = "Requests"
3899used = "/requests/used"
3900limit = "/requests/limit"
3901resets_at = "/requests/reset"
3902window_secs = 3600
3903[[custom.texts]]
3904label = "Tier"
3905value = "/tier"
3906"#;
3907
3908    fn custom_with(from: &str, to: &str) -> String {
3909        assert!(CUSTOM_BLOCK.contains(from), "fixture has no {from:?}");
3910        CUSTOM_BLOCK.replace(from, to)
3911    }
3912
3913    fn custom_error(toml: &str) -> String {
3914        Config::load_from(write_toml(toml).path())
3915            .unwrap_err()
3916            .to_string()
3917    }
3918
3919    fn assert_custom_rejected(toml: &str, needle: &str) {
3920        let msg = custom_error(toml);
3921        assert!(msg.contains(needle), "expected {needle:?} in: {msg}");
3922        assert!(
3923            msg.contains("[[custom]]"),
3924            "the error must locate the section: {msg}"
3925        );
3926    }
3927
3928    #[test]
3929    fn custom_block_parses_every_field() {
3930        let config = Config::load_from(write_toml(CUSTOM_BLOCK).path()).unwrap();
3931        assert_eq!(config.custom.len(), 1);
3932        let c = &config.custom[0];
3933        assert_eq!(c.id, "mytool");
3934        assert_eq!(c.name, "My Tool");
3935        assert_eq!(c.short_name, "myt");
3936        assert_eq!(
3937            c.brand, None,
3938            "a custom provider has no mark unless it asks"
3939        );
3940        assert!(c.enabled);
3941        assert_eq!(c.url, "https://api.example.test/v1/usage");
3942        assert!(!c.allow_http);
3943        assert_eq!(c.api_key_env, "MYTOOL_API_KEY");
3944        assert_eq!(c.api_key, None);
3945        assert_eq!(c.auth_header, "Authorization");
3946        assert_eq!(c.auth_scheme, "Bearer");
3947        assert_eq!(c.headers.get("X-Org").map(String::as_str), Some("org_1"));
3948        assert_eq!(c.plan.as_deref(), Some("Pro"));
3949        assert_eq!(c.plan_path, None);
3950        assert_eq!(c.cache_ttl(), std::time::Duration::from_secs(120));
3951        assert_eq!(c.metrics.len(), 1);
3952        assert_eq!(c.metrics[0].label, "Requests");
3953        assert_eq!(c.metrics[0].used.as_deref(), Some("/requests/used"));
3954        assert_eq!(c.metrics[0].limit.as_deref(), Some("/requests/limit"));
3955        assert_eq!(c.metrics[0].percent, None);
3956        assert_eq!(c.metrics[0].resets_at.as_deref(), Some("/requests/reset"));
3957        assert_eq!(c.metrics[0].window_secs, Some(3600));
3958        assert_eq!(c.texts.len(), 1);
3959        assert_eq!(c.texts[0].label, "Tier");
3960        assert_eq!(c.texts[0].value, "/tier");
3961        assert_eq!(c.section_label(), r#"[[custom]] id = "mytool""#);
3962    }
3963
3964    #[test]
3965    fn custom_defaults_are_the_documented_ones_and_name_falls_back_to_id() {
3966        let config: Config = toml::from_str(
3967            r#"
3968            [[custom]]
3969            id = "bare"
3970            short_name = "bre"
3971            url = "https://example.test/u"
3972            [[custom.metrics]]
3973            label = "Q"
3974            percent = "/pct"
3975            "#,
3976        )
3977        .unwrap();
3978        let c = &config.custom[0];
3979        assert_eq!(c.name, "bare", "name must default to id on a plain parse");
3980        assert!(!c.enabled);
3981        assert!(!c.allow_http);
3982        assert_eq!(c.api_key_env, "");
3983        assert_eq!(c.auth_header, "Authorization");
3984        assert_eq!(c.auth_scheme, "Bearer");
3985        assert_eq!(c.cache_ttl_secs, 60);
3986        assert!(config.validate().is_ok());
3987        assert!(Config::default().custom.is_empty());
3988    }
3989
3990    #[test]
3991    fn custom_brand_names_a_builtin_vendor_and_nothing_else() {
3992        let config = Config::load_from(
3993            write_toml(&custom_with(
3994                r#"short_name = "myt""#,
3995                "short_name = \"myt\"\nbrand = \"opencode-go\"",
3996            ))
3997            .path(),
3998        )
3999        .unwrap();
4000        assert_eq!(config.custom[0].brand.as_deref(), Some("opencode-go"));
4001
4002        // The mark is borrowed from a vendor, so only a vendor can name one.
4003        // A free-form slug here would reach the frontend as artwork it does
4004        // not ship and draw nothing at all.
4005        for brand in ["opencode", "OpenCode-Go", "mytool", ""] {
4006            assert_custom_rejected(
4007                &custom_with(
4008                    r#"short_name = "myt""#,
4009                    &format!("short_name = \"myt\"\nbrand = {brand:?}"),
4010                ),
4011                "must name a built-in vendor",
4012            );
4013        }
4014    }
4015
4016    #[test]
4017    fn custom_rejects_a_malformed_id() {
4018        let long = "a".repeat(33);
4019        for id in ["", "My Tool", "-lead", "UPPER", long.as_str()] {
4020            let msg = custom_error(&custom_with(r#"id = "mytool""#, &format!("id = {id:?}")));
4021            assert!(msg.contains("[[custom]] entry #1"), "{id:?}: {msg}");
4022            assert!(msg.contains("must match"), "{id:?}: {msg}");
4023        }
4024    }
4025
4026    #[test]
4027    fn custom_rejects_a_builtin_slug_as_id() {
4028        assert_custom_rejected(
4029            &custom_with(r#"id = "mytool""#, r#"id = "deepseek""#),
4030            "is a built-in vendor",
4031        );
4032        assert_custom_rejected(
4033            &custom_with(r#"id = "mytool""#, r#"id = "opencode-go""#),
4034            "is a built-in vendor",
4035        );
4036    }
4037
4038    #[test]
4039    fn custom_rejects_duplicate_ids() {
4040        let twice = format!(
4041            "{}{}",
4042            CUSTOM_BLOCK,
4043            custom_with(r#"short_name = "myt""#, r#"short_name = "myu""#)
4044        );
4045        assert_custom_rejected(&twice, "duplicate id");
4046    }
4047
4048    #[test]
4049    fn custom_rejects_a_name_over_48_chars() {
4050        let long = "n".repeat(49);
4051        assert_custom_rejected(
4052            &custom_with(r#"name = "My Tool""#, &format!("name = {long:?}")),
4053            "name must be 1 to 48 characters",
4054        );
4055    }
4056
4057    #[test]
4058    fn custom_rejects_a_short_name_that_is_not_three_lowercase_letters() {
4059        for short in ["my", "myto", "MYT", "m1t"] {
4060            assert_custom_rejected(
4061                &custom_with(r#"short_name = "myt""#, &format!("short_name = {short:?}")),
4062                "exactly 3 lowercase ASCII letters",
4063            );
4064        }
4065    }
4066
4067    #[test]
4068    fn custom_rejects_a_short_name_taken_by_a_builtin_or_another_entry() {
4069        assert_custom_rejected(
4070            &custom_with(r#"short_name = "myt""#, r#"short_name = "dsk""#),
4071            "already used by a built-in vendor",
4072        );
4073        let twice = format!(
4074            "{}{}",
4075            CUSTOM_BLOCK,
4076            custom_with(r#"id = "mytool""#, r#"id = "othertool""#)
4077        );
4078        assert_custom_rejected(&twice, "already used by a built-in vendor");
4079    }
4080
4081    #[test]
4082    fn custom_rejects_http_unless_allowed() {
4083        let plain = custom_with(
4084            r#"url = "https://api.example.test/v1/usage""#,
4085            r#"url = "http://localhost:8080/usage""#,
4086        );
4087        assert_custom_rejected(&plain, "url must use https://");
4088        let allowed = plain.replace(
4089            r#"url = "http://localhost:8080/usage""#,
4090            "url = \"http://localhost:8080/usage\"\nallow_http = true",
4091        );
4092        assert!(
4093            Config::load_from(write_toml(&allowed).path()).is_ok(),
4094            "allow_http must permit http://"
4095        );
4096    }
4097
4098    #[test]
4099    fn custom_rejects_a_url_with_userinfo_or_a_bad_scheme_or_garbage() {
4100        assert_custom_rejected(
4101            &custom_with(
4102                r#"url = "https://api.example.test/v1/usage""#,
4103                r#"url = "https://user:pw@api.example.test/v1/usage""#,
4104            ),
4105            "must not carry credentials",
4106        );
4107        assert_custom_rejected(
4108            &custom_with(
4109                r#"url = "https://api.example.test/v1/usage""#,
4110                r#"url = "not a url""#,
4111            ),
4112            "is not a valid URL",
4113        );
4114        assert_custom_rejected(
4115            &custom_with(
4116                r#"url = "https://api.example.test/v1/usage""#,
4117                r#"url = "ftp://api.example.test/v1/usage""#,
4118            ),
4119            "is not http or https",
4120        );
4121    }
4122
4123    #[test]
4124    fn custom_rejects_an_invalid_api_key_env() {
4125        assert_custom_rejected(
4126            &custom_with(
4127                r#"api_key_env = "MYTOOL_API_KEY""#,
4128                r#"api_key_env = "1BAD-NAME""#,
4129            ),
4130            "is not a valid environment variable name",
4131        );
4132        let none = custom_with(r#"api_key_env = "MYTOOL_API_KEY""#, r#"api_key_env = """#);
4133        assert!(
4134            Config::load_from(write_toml(&none).path()).is_ok(),
4135            "an empty api_key_env means inline-only and is valid"
4136        );
4137    }
4138
4139    #[test]
4140    fn custom_rejects_an_invalid_auth_header_name() {
4141        assert_custom_rejected(
4142            &custom_with(
4143                r#"auth_header = "Authorization""#,
4144                r#"auth_header = "X Api Key""#,
4145            ),
4146            "auth_header \"X Api Key\" is not a valid HTTP header name",
4147        );
4148    }
4149
4150    #[test]
4151    fn custom_rejects_a_control_char_in_auth_scheme() {
4152        assert_custom_rejected(
4153            &custom_with(
4154                r#"auth_scheme = "Bearer""#,
4155                "auth_scheme = \"Bearer\\u0007\"",
4156            ),
4157            "auth_scheme contains characters that are not valid",
4158        );
4159        let bare = custom_with(r#"auth_scheme = "Bearer""#, r#"auth_scheme = """#);
4160        assert!(
4161            Config::load_from(write_toml(&bare).path()).is_ok(),
4162            "an empty scheme (bare key) is valid"
4163        );
4164    }
4165
4166    #[test]
4167    fn custom_rejects_a_bad_extra_header() {
4168        assert_custom_rejected(
4169            &custom_with(r#"X-Org = "org_1""#, r#"authorization = "Bearer other""#),
4170            "headers must not repeat auth_header",
4171        );
4172        assert_custom_rejected(
4173            &custom_with(r#"X-Org = "org_1""#, r#""X Org" = "org_1""#),
4174            "is not a valid HTTP header name",
4175        );
4176        assert_custom_rejected(
4177            &custom_with(r#"X-Org = "org_1""#, "X-Org = \"org\\u0001\""),
4178            "has a value that is not valid in an HTTP header",
4179        );
4180    }
4181
4182    #[test]
4183    fn custom_rejects_cache_ttl_outside_10_to_3600() {
4184        for ttl in ["9", "3601"] {
4185            assert_custom_rejected(
4186                &custom_with("cache_ttl_secs = 120", &format!("cache_ttl_secs = {ttl}")),
4187                "cache_ttl_secs must be between 10 and 3600",
4188            );
4189        }
4190    }
4191
4192    #[test]
4193    fn custom_rejects_an_entry_with_no_metrics_or_texts() {
4194        let toml = r#"
4195[[custom]]
4196id = "empty"
4197short_name = "emp"
4198url = "https://example.test/u"
4199"#;
4200        assert_custom_rejected(toml, "at least one [[custom.metrics]] or [[custom.texts]]");
4201    }
4202
4203    #[test]
4204    fn custom_rejects_a_metric_mixing_percent_with_used_or_limit() {
4205        assert_custom_rejected(
4206            &custom_with(
4207                r#"limit = "/requests/limit""#,
4208                "limit = \"/requests/limit\"\npercent = \"/requests/pct\"",
4209            ),
4210            "must set `percent`, or both `used` and `limit`",
4211        );
4212        assert_custom_rejected(
4213            &custom_with("limit = \"/requests/limit\"\n", ""),
4214            "must set `percent`, or both `used` and `limit`",
4215        );
4216    }
4217
4218    #[test]
4219    fn custom_rejects_a_pointer_without_a_leading_slash() {
4220        assert_custom_rejected(
4221            &custom_with(r#"used = "/requests/used""#, r#"used = "requests.used""#),
4222            "used \"requests.used\" must be an RFC 6901 JSON Pointer",
4223        );
4224        assert_custom_rejected(
4225            &custom_with(r#"value = "/tier""#, r#"value = "tier""#),
4226            "value \"tier\" must be an RFC 6901 JSON Pointer",
4227        );
4228        assert_custom_rejected(
4229            &custom_with(r#"plan = "Pro""#, r#"plan_path = "plan""#),
4230            "plan_path \"plan\" must be an RFC 6901 JSON Pointer",
4231        );
4232        assert_custom_rejected(
4233            &custom_with(
4234                r#"resets_at = "/requests/reset""#,
4235                "resets_at = \"/re\\u001bset\"",
4236            ),
4237            "resets_at",
4238        );
4239    }
4240
4241    #[test]
4242    fn custom_rejects_a_label_outside_1_to_64_chars() {
4243        let long = "l".repeat(65);
4244        assert_custom_rejected(
4245            &custom_with(r#"label = "Requests""#, &format!("label = {long:?}")),
4246            "metric label",
4247        );
4248        assert_custom_rejected(
4249            &custom_with(r#"label = "Tier""#, r#"label = """#),
4250            "text label \"\" must be 1 to 64 characters",
4251        );
4252    }
4253
4254    #[test]
4255    fn custom_rejects_window_secs_under_60() {
4256        assert_custom_rejected(
4257            &custom_with("window_secs = 3600", "window_secs = 59"),
4258            "window_secs must be at least 60",
4259        );
4260    }
4261
4262    #[test]
4263    fn custom_rejects_duplicate_metric_and_text_labels() {
4264        let metric_twice = custom_with(
4265            "window_secs = 3600\n",
4266            "window_secs = 3600\n[[custom.metrics]]\nlabel = \"Requests\"\npercent = \"/pct\"\n",
4267        );
4268        assert_custom_rejected(&metric_twice, "duplicate metric label \"Requests\"");
4269        let text_twice =
4270            format!("{CUSTOM_BLOCK}[[custom.texts]]\nlabel = \"Tier\"\nvalue = \"/other\"\n");
4271        assert_custom_rejected(&text_twice, "duplicate text label \"Tier\"");
4272    }
4273
4274    #[test]
4275    fn enabled_custom_and_custom_by_id_select_entries() {
4276        let two = format!(
4277            "{}{}",
4278            CUSTOM_BLOCK,
4279            custom_with(r#"id = "mytool""#, r#"id = "off""#)
4280                .replace(r#"short_name = "myt""#, r#"short_name = "off""#)
4281                .replace("enabled = true", "enabled = false")
4282        );
4283        let config = Config::load_from(write_toml(&two).path()).unwrap();
4284        let enabled: Vec<&str> = config.enabled_custom().map(|c| c.id.as_str()).collect();
4285        assert_eq!(enabled, ["mytool"]);
4286        assert_eq!(
4287            config.custom_by_id("off").map(|c| c.name.as_str()),
4288            Some("My Tool")
4289        );
4290        assert!(config.custom_by_id("nope").is_none());
4291    }
4292
4293    #[cfg(unix)]
4294    #[test]
4295    fn has_inline_secrets_sees_a_custom_inline_key() {
4296        let without: Config = toml::from_str(CUSTOM_BLOCK).unwrap();
4297        assert!(!without.has_inline_secrets());
4298        let with: Config = toml::from_str(&custom_with(
4299            r#"api_key_env = "MYTOOL_API_KEY""#,
4300            "api_key_env = \"MYTOOL_API_KEY\"\napi_key = \"sk-inline\"",
4301        ))
4302        .unwrap();
4303        assert!(with.has_inline_secrets());
4304    }
4305
4306    #[test]
4307    fn custom_resolve_api_key_prefers_env_then_inline_then_errors_without_the_key() {
4308        let var = "AI_USAGEBAR_CUSTOM_TEST_KEY_51C2";
4309        let mut spec = CustomProviderConfig {
4310            id: "mytool".into(),
4311            api_key_env: var.into(),
4312            api_key: Some("sk-inline-secret".into()),
4313            ..CustomProviderConfig::default()
4314        };
4315        unsafe { std::env::set_var(var, "sk-env-secret") };
4316        let from_env = spec.resolve_api_key();
4317        unsafe { std::env::remove_var(var) };
4318        assert_eq!(from_env.unwrap(), "sk-env-secret");
4319
4320        assert_eq!(spec.resolve_api_key().unwrap(), "sk-inline-secret");
4321
4322        spec.api_key = Some(String::new());
4323        let err = spec.resolve_api_key().unwrap_err();
4324        assert!(matches!(err, AppError::Credentials(_)), "{err:?}");
4325        let msg = err.to_string();
4326        assert!(msg.contains(r#"[[custom]] id = "mytool""#), "{msg}");
4327        assert!(msg.contains(var), "{msg}");
4328        assert!(!msg.contains("secret"), "{msg}");
4329
4330        spec.api_key_env = String::new();
4331        let msg = spec.resolve_api_key().unwrap_err().to_string();
4332        assert!(msg.contains("set `api_key`"), "{msg}");
4333    }
4334
4335    #[test]
4336    fn loading_a_config_registers_custom_env_vars_for_scrubbing() {
4337        let var = "AI_USAGEBAR_CUSTOM_SCRUB_TEST_9B1D";
4338        assert!(!crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&var));
4339        let file = write_toml(&custom_with("MYTOOL_API_KEY", var));
4340        Config::load_from(file.path()).unwrap();
4341        assert!(
4342            crate::vendor::vendor_secret_env_vars_to_remove(&[]).contains(&var),
4343            "a custom provider's env var must be scrubbed from subprocesses"
4344        );
4345    }
4346
4347    /// `VendorId::config_section` is what every by-name config writer uses;
4348    /// this proves each section name is one the parser actually recognizes
4349    /// (the `deny_unknown_fields` on `Config` makes a misspelling fail loudly)
4350    /// and lands on that vendor's `enabled` switch.
4351    #[test]
4352    fn every_config_section_parses_to_its_vendors_enabled_switch() {
4353        for vendor in VendorId::all() {
4354            let text = format!(
4355                "[{}]
4356enabled = true
4357",
4358                vendor.config_section()
4359            );
4360            let config: Config = toml::from_str(&text)
4361                .unwrap_or_else(|e| panic!("{}: {e}", vendor.config_section()));
4362            assert!(config.is_enabled(*vendor), "{}", vendor.config_section());
4363            let others = VendorId::all()
4364                .iter()
4365                .filter(|other| *other != vendor && config.is_enabled(**other))
4366                .count();
4367            assert_eq!(
4368                others,
4369                Config::default().enabled_vendors().len()
4370                    - usize::from(Config::default().is_enabled(*vendor)),
4371                "[{}] enabled a different vendor",
4372                vendor.config_section()
4373            );
4374        }
4375    }
4376
4377    #[test]
4378    fn tray_section_parses_and_defaults_to_notify() {
4379        let file = write_toml("[tray]\nshortcut = \"Ctrl+Shift+U\"\nupdates = \"auto\"\n");
4380        let config = Config::load_from(file.path()).unwrap();
4381        assert_eq!(config.tray.shortcut.as_deref(), Some("Ctrl+Shift+U"));
4382        assert_eq!(config.tray.updates(), UpdateMode::Auto);
4383
4384        let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
4385        assert_eq!(empty.tray, TrayConfig::default());
4386        assert_eq!(empty.tray.updates(), UpdateMode::Notify);
4387        assert_eq!(UpdateMode::parse(" Off "), Some(UpdateMode::Off));
4388        assert_eq!(UpdateMode::parse("weekly"), None);
4389        assert_eq!(UpdateMode::Auto.as_str(), "auto");
4390    }
4391
4392    #[test]
4393    fn tray_section_rejects_a_misspelled_mode() {
4394        let file = write_toml("[tray]\nupdates = \"sometimes\"\n");
4395        assert!(Config::load_from(file.path()).is_err());
4396    }
4397
4398    #[test]
4399    fn tray_refresh_minutes_defaults_to_five_and_parses() {
4400        let empty = Config::load_from(write_toml("[ui]\n").path()).unwrap();
4401        assert_eq!(empty.tray.refresh_minutes, None);
4402        assert_eq!(empty.tray.refresh_minutes(), 5);
4403
4404        let file = write_toml("[tray]\nrefresh_minutes = 10\n");
4405        let config = Config::load_from(file.path()).unwrap();
4406        assert_eq!(config.tray.refresh_minutes(), 10);
4407    }
4408
4409    #[test]
4410    fn tray_refresh_minutes_rejects_values_outside_the_menu() {
4411        for minutes in ["3", "0"] {
4412            let file = write_toml(&format!("[tray]\nrefresh_minutes = {minutes}\n"));
4413            let error = Config::load_from(file.path()).unwrap_err().to_string();
4414            assert!(error.contains("[tray] refresh_minutes"), "{error}");
4415            assert!(error.contains("1, 5 or 10"), "{error}");
4416        }
4417    }
4418
4419    #[test]
4420    fn set_tray_value_writes_refresh_minutes_as_an_integer() {
4421        let dir = tempfile::tempdir().unwrap();
4422        let path = dir.path().join("config.toml");
4423        std::fs::write(&path, "[tray]\nrefresh_minutes = 5 # mine\n").unwrap();
4424
4425        set_tray_value(&path, "refresh_minutes", Some(10i64.into())).unwrap();
4426        let text = std::fs::read_to_string(&path).unwrap();
4427        assert_eq!(text, "[tray]\nrefresh_minutes = 10 # mine\n");
4428        assert_eq!(Config::load_from(&path).unwrap().tray.refresh_minutes(), 10);
4429
4430        set_tray_value(&path, "refresh_minutes", None).unwrap();
4431        assert_eq!(Config::load_from(&path).unwrap().tray.refresh_minutes(), 5);
4432    }
4433
4434    #[test]
4435    fn set_tray_value_creates_replaces_and_removes_keys() {
4436        let dir = tempfile::tempdir().unwrap();
4437        let path = dir.path().join("config.toml");
4438        std::fs::write(&path, "[ui]\n# primary = \"anthropic\"\n").unwrap();
4439
4440        set_tray_value(&path, "shortcut", Some("Ctrl+Shift+U".into())).unwrap();
4441        let text = std::fs::read_to_string(&path).unwrap();
4442        assert!(text.contains("# primary = \"anthropic\""), "{text}");
4443        assert!(
4444            text.contains("[tray]\nshortcut = \"Ctrl+Shift+U\""),
4445            "{text}"
4446        );
4447
4448        set_tray_value(&path, "shortcut", Some("Alt+F5".into())).unwrap();
4449        set_tray_value(&path, "updates", Some("off".into())).unwrap();
4450        let config = Config::load_from(&path).unwrap();
4451        assert_eq!(config.tray.shortcut.as_deref(), Some("Alt+F5"));
4452        assert_eq!(config.tray.updates(), UpdateMode::Off);
4453
4454        set_tray_value(&path, "shortcut", None).unwrap();
4455        let text = std::fs::read_to_string(&path).unwrap();
4456        assert!(!text.contains("shortcut"), "{text}");
4457        assert!(text.contains("updates = \"off\""), "{text}");
4458
4459        // Idempotent removal does not rewrite the file.
4460        let mtime = std::fs::metadata(&path).unwrap().modified().unwrap();
4461        set_tray_value(&path, "shortcut", None).unwrap();
4462        assert_eq!(std::fs::metadata(&path).unwrap().modified().unwrap(), mtime);
4463    }
4464
4465    #[test]
4466    fn set_value_keeps_the_trailing_comment_when_replacing() {
4467        let mut doc: toml_edit::DocumentMut =
4468            "[tray]\nshortcut = \"Ctrl+U\" # mine\n".parse().unwrap();
4469        set_value(&mut doc, "tray", "shortcut", Some("Alt+U".into())).unwrap();
4470        assert_eq!(doc.to_string(), "[tray]\nshortcut = \"Alt+U\" # mine\n");
4471    }
4472
4473    #[test]
4474    fn enable_vendors_in_creates_a_missing_config() {
4475        let dir = tempfile::TempDir::new().unwrap();
4476        let path = dir.path().join("sub").join("config.toml");
4477
4478        enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
4479
4480        assert_eq!(
4481            std::fs::read_to_string(&path).unwrap(),
4482            "[grok]
4483enabled = true
4484"
4485        );
4486        assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
4487    }
4488
4489    #[test]
4490    fn enable_vendors_in_keeps_comments_and_appends_the_new_section() {
4491        let dir = tempfile::TempDir::new().unwrap();
4492        let path = dir.path().join("config.toml");
4493        let original = "# my settings
4494[zai]
4495api_key = \"x\" # keep
4496enabled = false
4497";
4498        std::fs::write(&path, original).unwrap();
4499
4500        enable_vendors_in(&path, &[VendorId::Grok, VendorId::OpenCodeGo]).unwrap();
4501
4502        let text = std::fs::read_to_string(&path).unwrap();
4503        assert!(
4504            text.starts_with(
4505                "# my settings
4506"
4507            ),
4508            "{text}"
4509        );
4510        assert!(
4511            text.contains(
4512                "api_key = \"x\" # keep
4513"
4514            ),
4515            "{text}"
4516        );
4517        assert!(
4518            text.contains(
4519                "[grok]
4520enabled = true
4521"
4522            ),
4523            "{text}"
4524        );
4525        assert!(
4526            text.contains(
4527                "[opencode-go]
4528enabled = true
4529"
4530            ),
4531            "{text}"
4532        );
4533        let config = Config::load_from(&path).unwrap();
4534        assert!(
4535            !config.is_enabled(VendorId::Zai),
4536            "never widens to false, never flips others"
4537        );
4538        assert!(config.is_enabled(VendorId::Grok));
4539        assert!(config.is_enabled(VendorId::OpenCodeGo));
4540    }
4541
4542    #[test]
4543    fn enable_vendors_in_leaves_an_explicit_false_alone() {
4544        let dir = tempfile::TempDir::new().unwrap();
4545        let path = dir.path().join("config.toml");
4546        let original = "[grok]
4547enabled = false # off
4548api_key = \"k\"
4549";
4550        std::fs::write(&path, original).unwrap();
4551
4552        // `enabled = false` in the file is the user having said no. Only the
4553        // automatic path goes through here — the Settings overlay writes with
4554        // `set_bool` — so nothing a person does by hand is blocked by this.
4555        let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
4556
4557        assert!(written.is_empty(), "{written:?}");
4558        assert_eq!(
4559            std::fs::read_to_string(&path).unwrap(),
4560            original,
4561            "the file must not be rewritten at all"
4562        );
4563    }
4564
4565    #[test]
4566    fn enable_vendors_in_adds_the_switch_when_the_config_never_mentioned_it() {
4567        let dir = tempfile::TempDir::new().unwrap();
4568        let path = dir.path().join("config.toml");
4569        std::fs::write(&path, "[grok]\napi_key = \"k\"\n").unwrap();
4570
4571        let written = enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
4572
4573        assert_eq!(written, vec![VendorId::Grok]);
4574        assert!(Config::load_from(&path).unwrap().is_enabled(VendorId::Grok));
4575    }
4576
4577    #[test]
4578    fn enable_vendors_in_is_textually_idempotent() {
4579        let dir = tempfile::TempDir::new().unwrap();
4580        let path = dir.path().join("config.toml");
4581        let original = "[grok]
4582enabled = true
4583
4584# trailing
4585";
4586        std::fs::write(&path, original).unwrap();
4587        let before = std::fs::metadata(&path).unwrap().modified().unwrap();
4588
4589        enable_vendors_in(&path, &[VendorId::Grok]).unwrap();
4590
4591        assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
4592        assert_eq!(
4593            std::fs::metadata(&path).unwrap().modified().unwrap(),
4594            before,
4595            "an unchanged document must not be rewritten"
4596        );
4597    }
4598
4599    #[test]
4600    fn enable_vendors_in_with_nothing_to_enable_leaves_a_missing_file_missing() {
4601        let dir = tempfile::TempDir::new().unwrap();
4602        let path = dir.path().join("config.toml");
4603
4604        enable_vendors_in(&path, &[]).unwrap();
4605
4606        assert!(!path.exists());
4607    }
4608}