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//! [zai]        enabled = true
8//! [openrouter] enabled = true
9//! [deepseek]   enabled = false
10//! [kimi]       enabled = false
11//! ```
12//!
13//! Every field is optional with sensible defaults — missing config file is
14//! treated as "use defaults". API keys are read from env vars (the relevant
15//! `*_api_key_env` field lets the user override which env var name).
16
17use std::collections::HashSet;
18use std::path::PathBuf;
19
20use serde::{Deserialize, Serialize};
21
22use crate::anthropic::creds::CredsTarget;
23use crate::cache::Cache;
24use crate::error::{AppError, Result};
25use crate::vendor::VendorId;
26
27#[derive(Debug, Clone, Default, Deserialize, Serialize)]
28#[serde(default)]
29pub struct Config {
30    pub ui: UiConfig,
31    pub anthropic: AnthropicConfig,
32    pub openai: OpenAiConfig,
33    pub zai: ZaiConfig,
34    pub openrouter: OpenRouterConfig,
35    pub deepseek: DeepseekConfig,
36    pub kimi: KimiConfig,
37}
38
39/// UI / dispatch preferences. Currently just `primary` — which vendor the
40/// widget shows when `--vendor` is omitted, and which TUI tab is selected
41/// at startup.
42#[derive(Debug, Clone, Default, Deserialize, Serialize)]
43#[serde(default)]
44pub struct UiConfig {
45    /// `None` → fall back to anthropic for backward compatibility.
46    pub primary: Option<VendorId>,
47}
48
49#[derive(Debug, Clone, Deserialize, Serialize)]
50#[serde(default)]
51pub struct AnthropicConfig {
52    pub enabled: bool,
53    /// Override the credentials file path (defaults to `~/.claude/.credentials.json`).
54    /// This is the *default* account; extra subscriptions go in `accounts`.
55    pub credentials_path: Option<PathBuf>,
56    /// Extra Anthropic accounts beyond the default, each selected on the CLI
57    /// with `--account <label>` (issue #14). Empty by default, so existing
58    /// single-account configs are byte-for-byte unchanged.
59    pub accounts: Vec<AnthropicAccount>,
60}
61
62impl Default for AnthropicConfig {
63    fn default() -> Self {
64        Self {
65            enabled: true,
66            credentials_path: None,
67            accounts: Vec::new(),
68        }
69    }
70}
71
72/// One extra Anthropic account beyond the default (issue #14). The default
73/// account stays the singular `[anthropic] credentials_path`; each entry here
74/// is an additional subscription selected on the CLI with `--account <label>`.
75///
76/// ```toml
77/// [[anthropic.accounts]]
78/// label = "work"
79/// credentials_path = "~/.config/ai-usagebar/accounts/work.json"
80/// ```
81#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
82pub struct AnthropicAccount {
83    /// Stable name used on the CLI (`--account <label>`) and as the cache
84    /// subdir (`~/.cache/ai-usagebar/anthropic/<label>`).
85    pub label: String,
86    /// OAuth credentials file for this account (same JSON shape Claude Code
87    /// writes). Token refreshes are written back here, so each account keeps
88    /// itself alive independently.
89    pub credentials_path: PathBuf,
90}
91
92impl AnthropicConfig {
93    /// Find a configured extra account by label, or error listing the known
94    /// labels so a typo fails loudly instead of silently hitting the default.
95    pub fn account(&self, label: &str) -> Result<&AnthropicAccount> {
96        validate_account_label(label)?;
97        self.accounts
98            .iter()
99            .find(|a| a.label == label)
100            .ok_or_else(|| {
101                let known: Vec<&str> = self.accounts.iter().map(|a| a.label.as_str()).collect();
102                AppError::Credentials(format!(
103                    "anthropic account {label:?} not found in [[anthropic.accounts]]; \
104                     known labels: {known:?}"
105                ))
106            })
107    }
108
109    /// Resolve a named account to the credentials target + isolated cache it
110    /// fetches through: a strict [`CredsTarget::Explicit`] on the account's file
111    /// (never the Keychain — issue #15) and an `anthropic/<label>` cache subdir.
112    /// Shared by the widget (`--account`) and the TUI's per-account tab (#14,
113    /// #17) so both resolve accounts identically; the widget layers its
114    /// `--cache-dir` override on top of the cache returned here.
115    pub fn account_target(&self, label: &str) -> Result<(CredsTarget, Cache)> {
116        let account = self.account(label)?;
117        Ok((
118            CredsTarget::Explicit(account.credentials_path.clone()),
119            Cache::for_vendor_account("anthropic", label)?,
120        ))
121    }
122}
123
124/// The label doubles as a cache subdirectory name
125/// (`~/.cache/ai-usagebar/anthropic/<label>/`), which nests inside the default
126/// account's cache dir — so path separators or dot-dirs would escape or
127/// collide with the cache layout (`usage.json`, `.stale`, …). Reject anything
128/// that isn't a plain single-segment name.
129fn validate_account_label(label: &str) -> Result<()> {
130    let bad = label.is_empty()
131        || label == "."
132        || label == ".."
133        || label.contains(['/', '\\'])
134        || label == "usage.json";
135    if bad {
136        return Err(AppError::Credentials(format!(
137            "invalid anthropic account label {label:?}: must be a non-empty name \
138             without path separators (it becomes a cache subdirectory)"
139        )));
140    }
141    Ok(())
142}
143
144#[derive(Debug, Clone, Deserialize, Serialize)]
145#[serde(default)]
146pub struct OpenAiConfig {
147    pub enabled: bool,
148    /// Override the Codex auth file path (defaults to `~/.codex/auth.json`).
149    pub codex_auth_path: Option<PathBuf>,
150    /// Optional admin key env var name for the API-key-only fallback path.
151    pub admin_key_env: String,
152}
153
154impl Default for OpenAiConfig {
155    fn default() -> Self {
156        Self {
157            enabled: true,
158            codex_auth_path: None,
159            admin_key_env: "OPENAI_ADMIN_KEY".to_string(),
160        }
161    }
162}
163
164#[derive(Debug, Clone, Deserialize, Serialize)]
165#[serde(default)]
166pub struct ZaiConfig {
167    pub enabled: bool,
168    /// Env var name to read the key from (env wins over `api_key`).
169    pub api_key_env: String,
170    /// Inline key (fallback when the env var is unset). Chmod 600 your
171    /// config file if you put a real key here.
172    pub api_key: Option<String>,
173    /// Optional plan tier label (lite/pro/max) — display-only.
174    pub plan_tier: Option<String>,
175}
176
177impl Default for ZaiConfig {
178    fn default() -> Self {
179        Self {
180            enabled: true,
181            api_key_env: "ZAI_API_KEY".to_string(),
182            api_key: None,
183            plan_tier: None,
184        }
185    }
186}
187
188#[derive(Debug, Clone, Deserialize, Serialize)]
189#[serde(default)]
190pub struct OpenRouterConfig {
191    pub enabled: bool,
192    pub api_key_env: String,
193    pub api_key: Option<String>,
194}
195
196impl Default for OpenRouterConfig {
197    fn default() -> Self {
198        Self {
199            enabled: true,
200            api_key_env: "OPENROUTER_API_KEY".to_string(),
201            api_key: None,
202        }
203    }
204}
205
206#[derive(Debug, Clone, Deserialize, Serialize)]
207#[serde(default)]
208pub struct DeepseekConfig {
209    pub enabled: bool,
210    pub api_key_env: String,
211    pub api_key: Option<String>,
212}
213
214impl Default for DeepseekConfig {
215    fn default() -> Self {
216        Self {
217            enabled: false,
218            api_key_env: "DEEPSEEK_API_KEY".to_string(),
219            api_key: None,
220        }
221    }
222}
223
224#[derive(Debug, Clone, Deserialize, Serialize)]
225#[serde(default)]
226pub struct KimiConfig {
227    pub enabled: bool,
228    pub api_key_env: String,
229    pub api_key: Option<String>,
230}
231
232impl Default for KimiConfig {
233    fn default() -> Self {
234        Self {
235            enabled: false,
236            api_key_env: "KIMI_API_KEY".to_string(),
237            api_key: None,
238        }
239    }
240}
241
242/// Resolve an API key for a vendor: a valid env-var name wins, then inline
243/// config. Used by Z.AI, OpenRouter, DeepSeek, and Kimi vendors.
244pub fn resolve_api_key(
245    vendor_label: &str,
246    env_var_name: &str,
247    inline: Option<&str>,
248) -> crate::error::Result<String> {
249    let valid_env_name = is_valid_env_var_name(env_var_name);
250    if valid_env_name
251        && let Ok(v) = std::env::var(env_var_name)
252        && !v.is_empty()
253    {
254        return Ok(v);
255    }
256    if let Some(v) = inline
257        && !v.is_empty()
258    {
259        return Ok(v.to_string());
260    }
261    let advice = if valid_env_name {
262        "set an API key in a valid environment variable or set `api_key`"
263    } else {
264        "fix the invalid `api_key_env` with a valid environment variable name or set `api_key`"
265    };
266    Err(crate::error::AppError::Credentials(format!(
267        "{vendor_label}: no API key. Either {advice} under [{}] in {}.",
268        vendor_label.to_lowercase(),
269        config_path_hint()
270    )))
271}
272
273fn is_valid_env_var_name(name: &str) -> bool {
274    let mut chars = name.chars();
275    let Some(first) = chars.next() else {
276        return false;
277    };
278    (first.is_ascii_alphabetic() || first == '_')
279        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
280}
281
282impl Config {
283    /// Load from `~/.config/ai-usagebar/config.toml`. Returns defaults if the
284    /// file doesn't exist; errors only on actual parse failures.
285    pub fn load() -> Result<Self> {
286        let Some(path) = default_path() else {
287            return Ok(Self::default());
288        };
289        Self::load_from(&path)
290    }
291
292    pub fn load_from(path: &std::path::Path) -> Result<Self> {
293        match std::fs::read_to_string(path) {
294            Ok(s) => {
295                let config: Self = toml::from_str(&s)?;
296                config.validate()?;
297                Ok(config)
298            }
299            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
300            Err(e) => Err(AppError::io_at(path, e)),
301        }
302    }
303
304    pub fn is_enabled(&self, id: VendorId) -> bool {
305        match id {
306            VendorId::Anthropic => self.anthropic.enabled,
307            VendorId::Openai => self.openai.enabled,
308            VendorId::Zai => self.zai.enabled,
309            VendorId::Openrouter => self.openrouter.enabled,
310            VendorId::Deepseek => self.deepseek.enabled,
311            VendorId::Kimi => self.kimi.enabled,
312        }
313    }
314
315    pub fn enabled_vendors(&self) -> Vec<VendorId> {
316        VendorId::all()
317            .iter()
318            .copied()
319            .filter(|id| self.is_enabled(*id))
320            .collect()
321    }
322
323    /// Validate cross-entry constraints that serde cannot express. Account
324    /// labels are both CLI selectors and TUI tab identities, so duplicates
325    /// would make either destination ambiguous.
326    pub fn validate(&self) -> Result<()> {
327        let mut labels = HashSet::new();
328        for account in &self.anthropic.accounts {
329            validate_account_label(&account.label)?;
330            if !labels.insert(&account.label) {
331                return Err(AppError::Credentials(format!(
332                    "duplicate anthropic account label {:?}",
333                    account.label
334                )));
335            }
336        }
337        Ok(())
338    }
339}
340
341pub fn default_path() -> Option<PathBuf> {
342    let proj = directories::ProjectDirs::from("", "", "ai-usagebar")?;
343    Some(proj.config_dir().join("config.toml"))
344}
345
346/// Resolved `config.toml` path as a string for user-facing messages. Uses the
347/// platform's config dir (`directories::ProjectDirs`), so it reads correctly on
348/// Linux, macOS, and Windows instead of hard-coding the Unix `~/.config` path.
349/// Falls back to the bare filename if the path can't be resolved.
350pub fn config_path_hint() -> String {
351    default_path()
352        .map(|p| p.display().to_string())
353        .unwrap_or_else(|| "config.toml".to_string())
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use std::io::Write;
360    use tempfile::NamedTempFile;
361
362    fn write_toml(s: &str) -> NamedTempFile {
363        let mut f = NamedTempFile::new().unwrap();
364        f.write_all(s.as_bytes()).unwrap();
365        f.flush().unwrap();
366        f
367    }
368
369    #[test]
370    fn defaults_enable_core_vendors_deepseek_and_kimi_default_off() {
371        let c = Config::default();
372        assert!(c.is_enabled(VendorId::Anthropic));
373        assert!(c.is_enabled(VendorId::Openai));
374        assert!(c.is_enabled(VendorId::Zai));
375        assert!(c.is_enabled(VendorId::Openrouter));
376        // DeepSeek and Kimi require explicit API keys, so they default to disabled.
377        assert!(!c.is_enabled(VendorId::Deepseek));
378        assert!(!c.is_enabled(VendorId::Kimi));
379        assert_eq!(c.enabled_vendors().len(), 4);
380    }
381
382    #[test]
383    fn missing_file_uses_defaults() {
384        let path = std::path::Path::new("/tmp/does-not-exist-ai-usagebar-test");
385        let c = Config::load_from(path).unwrap();
386        assert!(c.is_enabled(VendorId::Anthropic));
387    }
388
389    #[test]
390    fn parses_full_config() {
391        let f = write_toml(
392            r#"
393            [anthropic]
394            enabled = true
395
396            [openai]
397            enabled = false
398            admin_key_env = "MY_ADMIN_KEY"
399
400            [zai]
401            enabled = true
402            api_key_env = "MY_ZAI"
403            plan_tier = "pro"
404
405            [openrouter]
406            enabled = false
407            "#,
408        );
409        let c = Config::load_from(f.path()).unwrap();
410        assert!(c.is_enabled(VendorId::Anthropic));
411        assert!(!c.is_enabled(VendorId::Openai));
412        assert!(c.is_enabled(VendorId::Zai));
413        assert!(!c.is_enabled(VendorId::Openrouter));
414        assert_eq!(c.openai.admin_key_env, "MY_ADMIN_KEY");
415        assert_eq!(c.zai.api_key_env, "MY_ZAI");
416        assert_eq!(c.zai.plan_tier.as_deref(), Some("pro"));
417    }
418
419    #[test]
420    fn partial_config_falls_back_to_defaults() {
421        let f = write_toml(
422            r#"[openai]
423enabled = false
424"#,
425        );
426        let c = Config::load_from(f.path()).unwrap();
427        assert!(!c.is_enabled(VendorId::Openai));
428        // Other vendors keep their defaults.
429        assert!(c.is_enabled(VendorId::Anthropic));
430        assert_eq!(c.openai.admin_key_env, "OPENAI_ADMIN_KEY");
431    }
432
433    #[test]
434    fn malformed_toml_returns_error() {
435        let f = write_toml("this is not = = valid");
436        assert!(Config::load_from(f.path()).is_err());
437    }
438
439    // serial guard for env-var manipulation tests so they don't race
440    fn env_guard() -> std::sync::MutexGuard<'static, ()> {
441        static M: std::sync::Mutex<()> = std::sync::Mutex::new(());
442        M.lock().unwrap_or_else(|p| p.into_inner())
443    }
444
445    #[test]
446    fn resolve_api_key_prefers_env_over_inline() {
447        let _g = env_guard();
448        // Use a unique env var name so we don't clobber test parallelism.
449        let var = "AI_USAGEBAR_TEST_ENV_WINS";
450        // SAFETY: tests are single-threaded under env_guard.
451        unsafe { std::env::set_var(var, "from-env") };
452        let got = resolve_api_key("Zai", var, Some("from-inline")).unwrap();
453        unsafe { std::env::remove_var(var) };
454        assert_eq!(got, "from-env");
455    }
456
457    #[test]
458    fn resolve_api_key_falls_back_to_inline() {
459        let _g = env_guard();
460        let var = "AI_USAGEBAR_TEST_INLINE_FALLBACK";
461        unsafe { std::env::remove_var(var) };
462        let got = resolve_api_key("Zai", var, Some("inline-key")).unwrap();
463        assert_eq!(got, "inline-key");
464    }
465
466    #[test]
467    fn resolve_api_key_errors_when_both_missing() {
468        let _g = env_guard();
469        let var = "AI_USAGEBAR_TEST_BOTH_MISSING";
470        unsafe { std::env::remove_var(var) };
471        let err = resolve_api_key("Zai", var, None).unwrap_err();
472        match err {
473            crate::error::AppError::Credentials(msg) => {
474                assert!(
475                    msg.contains("api_key"),
476                    "error should suggest config field: {msg}"
477                );
478            }
479            other => panic!("expected Credentials error, got {other:?}"),
480        }
481    }
482
483    #[test]
484    fn config_path_hint_ends_with_config_toml() {
485        // Platform-resolved (Linux/macOS/Windows), but always ends in the
486        // config filename — the trailing segment is what messages rely on.
487        assert!(config_path_hint().ends_with("config.toml"));
488    }
489
490    #[test]
491    fn resolve_api_key_treats_empty_env_as_unset() {
492        let _g = env_guard();
493        let var = "AI_USAGEBAR_TEST_EMPTY_ENV";
494        unsafe { std::env::set_var(var, "") };
495        let got = resolve_api_key("OpenRouter", var, Some("inline")).unwrap();
496        unsafe { std::env::remove_var(var) };
497        assert_eq!(got, "inline");
498    }
499
500    #[test]
501    fn resolve_api_key_rejects_invalid_env_var_name_without_leaking_it() {
502        let _g = env_guard();
503        // Simulates a user accidentally pasting the key into api_key_env.
504        let bad = "sk-kimi-very-real-looking-pasted-secret";
505        let err = resolve_api_key("Kimi", bad, None).unwrap_err();
506        let msg = err.to_string();
507        assert!(
508            msg.contains("invalid") && msg.contains("api_key_env"),
509            "error should explain misconfiguration: {msg}"
510        );
511        assert!(
512            !msg.contains(bad),
513            "error must not echo the misconfigured value: {msg}"
514        );
515        assert!(msg.contains("valid environment variable name"));
516        assert!(
517            msg.contains("[kimi]"),
518            "error should point at the lowercase TOML section: {msg}"
519        );
520    }
521
522    #[test]
523    fn resolve_api_key_invalid_env_name_falls_back_to_inline() {
524        let _g = env_guard();
525        let got = resolve_api_key("Kimi", "sk-pasted-secret", Some("inline-key")).unwrap();
526        assert_eq!(got, "inline-key");
527    }
528
529    #[test]
530    fn resolve_api_key_never_leaks_valid_looking_configured_env_name() {
531        let _g = env_guard();
532        // This is syntactically a valid environment variable name, but could
533        // be a pasted secret and must not be reflected in the error.
534        let pasted_secret = "sk_pasted_secret";
535        unsafe { std::env::remove_var(pasted_secret) };
536        let err = resolve_api_key("Kimi", pasted_secret, None).unwrap_err();
537        assert!(
538            !err.to_string().contains(pasted_secret),
539            "error must not echo configured api_key_env values"
540        );
541    }
542
543    #[test]
544    fn is_valid_env_var_name_rules() {
545        // Valid: alphabetic or underscore first, then alnum/underscore.
546        for valid in ["KIMI_API_KEY", "_PRIVATE", "a", "Z9", "MY_ZAI_2"] {
547            assert!(is_valid_env_var_name(valid), "{valid} should be valid");
548        }
549        // Invalid: empty, digit-first, or shell-illegal characters.
550        for invalid in ["", "9LIVES", "sk-kimi", "MY KEY", "A.B", "sk/k"] {
551            assert!(
552                !is_valid_env_var_name(invalid),
553                "{invalid} should be invalid"
554            );
555        }
556    }
557
558    #[test]
559    fn config_parses_with_inline_api_key_and_primary() {
560        let f = write_toml(
561            r#"
562            [ui]
563            primary = "openrouter"
564
565            [zai]
566            enabled = true
567            api_key_env = "MY_ZAI"
568            api_key = "sk-zai-inline"
569
570            [openrouter]
571            enabled = true
572            api_key = "sk-or-inline"
573            "#,
574        );
575        let c = Config::load_from(f.path()).unwrap();
576        assert_eq!(c.ui.primary, Some(VendorId::Openrouter));
577        assert_eq!(c.zai.api_key.as_deref(), Some("sk-zai-inline"));
578        assert_eq!(c.openrouter.api_key.as_deref(), Some("sk-or-inline"));
579    }
580
581    #[test]
582    fn enabled_vendors_preserves_canonical_order() {
583        // DeepSeek and Kimi are disabled by default (require explicit API key
584        // config), so they are absent from the enabled list unless enabled.
585        let c = Config::default();
586        assert_eq!(
587            c.enabled_vendors(),
588            vec![
589                VendorId::Anthropic,
590                VendorId::Openai,
591                VendorId::Zai,
592                VendorId::Openrouter,
593            ]
594        );
595    }
596
597    #[test]
598    fn deepseek_appears_when_enabled() {
599        let f = write_toml(
600            r#"
601            [deepseek]
602            enabled = true
603            api_key = "sk-test"
604            "#,
605        );
606        let c = Config::load_from(f.path()).unwrap();
607        assert!(c.is_enabled(VendorId::Deepseek));
608        assert!(c.enabled_vendors().contains(&VendorId::Deepseek));
609        assert_eq!(c.deepseek.api_key.as_deref(), Some("sk-test"));
610    }
611
612    #[test]
613    fn kimi_appears_when_enabled() {
614        let f = write_toml(
615            r#"
616            [kimi]
617            enabled = true
618            api_key = "sk-test"
619            "#,
620        );
621        let c = Config::load_from(f.path()).unwrap();
622        assert!(c.is_enabled(VendorId::Kimi));
623        assert!(c.enabled_vendors().contains(&VendorId::Kimi));
624        assert_eq!(c.kimi.api_key.as_deref(), Some("sk-test"));
625    }
626
627    #[test]
628    fn enabled_deepseek_and_kimi_appear_in_canonical_order_ending_with_them() {
629        let f = write_toml(
630            r#"
631            [deepseek]
632            enabled = true
633            api_key = "sk-ds"
634
635            [kimi]
636            enabled = true
637            api_key = "sk-kimi"
638            "#,
639        );
640        let c = Config::load_from(f.path()).unwrap();
641        assert_eq!(
642            c.enabled_vendors(),
643            vec![
644                VendorId::Anthropic,
645                VendorId::Openai,
646                VendorId::Zai,
647                VendorId::Openrouter,
648                VendorId::Deepseek,
649                VendorId::Kimi,
650            ]
651        );
652    }
653
654    #[test]
655    fn parses_anthropic_accounts_and_looks_them_up() {
656        let f = write_toml(
657            r#"
658            [anthropic]
659            enabled = true
660
661            [[anthropic.accounts]]
662            label = "personal"
663            credentials_path = "/creds/personal.json"
664
665            [[anthropic.accounts]]
666            label = "work"
667            credentials_path = "/creds/work.json"
668            "#,
669        );
670        let c = Config::load_from(f.path()).unwrap();
671        assert_eq!(c.anthropic.accounts.len(), 2);
672        let work = c.anthropic.account("work").unwrap();
673        assert_eq!(work.credentials_path, PathBuf::from("/creds/work.json"));
674        // A typo names the offending label and lists the known ones.
675        let err = format!("{:?}", c.anthropic.account("missing").unwrap_err());
676        assert!(err.contains("missing") && err.contains("work"), "{err}");
677    }
678
679    #[test]
680    fn duplicate_anthropic_account_labels_are_rejected_on_load() {
681        let f = write_toml(
682            r#"
683            [[anthropic.accounts]]
684            label = "work"
685            credentials_path = "/creds/work-one.json"
686
687            [[anthropic.accounts]]
688            label = "work"
689            credentials_path = "/creds/work-two.json"
690            "#,
691        );
692        let err = Config::load_from(f.path()).unwrap_err().to_string();
693        assert!(
694            err.contains("duplicate anthropic account label \"work\""),
695            "{err}"
696        );
697    }
698
699    #[test]
700    fn account_label_rejects_path_like_names() {
701        let cfg = AnthropicConfig::default();
702        for bad in ["", ".", "..", "a/b", r"a\b", "usage.json"] {
703            let err = cfg.account(bad).unwrap_err();
704            assert!(
705                format!("{err:?}").contains("invalid anthropic account label"),
706                "{bad:?} should be rejected as a label"
707            );
708        }
709    }
710
711    #[test]
712    fn anthropic_accounts_default_to_empty() {
713        // No [[anthropic.accounts]] → the single default account, empty list,
714        // nothing to migrate (issue #14, back-compat rule 1).
715        assert!(Config::default().anthropic.accounts.is_empty());
716    }
717}