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