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