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