Skip to main content

vct_core/config/
mod.rs

1//! Persistent user settings (`~/.vct/config.toml`).
2//!
3//! A small typed [`Config`] read with serde and written back with `toml_edit`
4//! so hand-added comments and any unknown keys survive programmatic edits (the
5//! same "preserve what we don't own" idea as the credential write-back in
6//! `quota::refresh`). Reads are infallible: a missing or malformed file falls
7//! back to defaults.
8//!
9//! The typed structs are the single source of truth: their `#[serde(default)]`
10//! values and `///` doc-comments feed both the JSON schema (via `schemars`) and
11//! the commented default `config.toml` (generated by [`default_document`]), so
12//! there is no hand-maintained template to drift. On the first run the generated
13//! file is materialized with a `#:schema` directive pointing at the published
14//! schema so schema-aware editors validate it.
15//!
16//! Files written by an older vct are upgraded through two layers. On load,
17//! [`migrate_document`] rewrites a standard-`[header]`-table file in place —
18//! adding the `#:schema` directive, renaming `refresh_interval_secs` to
19//! `refresh_interval`, and moving `[usage].quota_panels` into the nested
20//! `[usage.quota]` table, and adding the startup auto-update preference, so an
21//! existing user actually gets the new layout (`vct config migrate` forces the
22//! same pass). A read-time [`migrate_legacy`]
23//! shim then backstops any residual legacy form the structural pass leaves alone
24//! (e.g. a hand-edited inline `usage = { ... }` table), so the returned [`Config`]
25//! is always correct even when the file was not rewritten.
26
27use crate::models::TimeRange;
28use crate::utils::{get_cache_dir, resolve_paths, write_string_atomic};
29use anyhow::Result;
30use schemars::JsonSchema;
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33use std::path::Path;
34use toml_edit::{Array, DocumentMut, Item, Table, TableLike, value};
35
36/// URL of the published JSON schema, referenced via a `#:schema` directive on
37/// the first line of the generated `config.toml` so schema-aware TOML editors
38/// (taplo / VS Code "Even Better TOML") offer autocomplete and validation.
39const SCHEMA_URL: &str =
40    "https://raw.githubusercontent.com/Mai0313/VibeCodingTracker/main/vct.schema.json";
41
42/// Current layout version of `config.toml`, stamped into `[general].version`.
43///
44/// It exists so an upgrade can add a setting to an existing file exactly once.
45/// Version 1 is any file written before the key existed.
46const CONFIG_VERSION: u32 = 3;
47
48/// Quota panels introduced after version 1, back-filled once into a file older
49/// than the version that shipped them.
50///
51/// Only the panel a given version *added* is back-filled, so a panel the user
52/// removed on purpose earlier is never resurrected. Extend this list (and bump
53/// [`CONFIG_VERSION`]) when a new provider gains a panel.
54const PANELS_ADDED_BY_VERSION: &[(u32, &str)] = &[(2, "grok")];
55
56/// The full settings document.
57#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
58pub struct Config {
59    #[serde(default)]
60    pub general: GeneralConfig,
61    #[serde(default)]
62    pub usage: UsageConfig,
63    #[serde(default)]
64    pub analysis: AnalysisConfig,
65    #[serde(default)]
66    pub performance: PerformanceConfig,
67    #[serde(default)]
68    pub providers: ProvidersConfig,
69    #[serde(default)]
70    pub logging: LoggingConfig,
71}
72
73/// `[performance]` - controls for CPU-bound local scans.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
75pub struct PerformanceConfig {
76    /// Rayon workers used by CLI session scans. `0` selects the measured auto
77    /// default; a positive value is capped at the machine's available parallelism.
78    #[serde(default)]
79    pub scan_threads: usize,
80}
81
82impl PerformanceConfig {
83    /// Resolves the CLI scan worker count.
84    ///
85    /// A positive config value wins, followed by a positive
86    /// `RAYON_NUM_THREADS`; otherwise auto mode uses at most two workers. Every
87    /// result is capped at the available parallelism and is at least one.
88    pub fn resolved_scan_threads(&self) -> usize {
89        let available = std::thread::available_parallelism()
90            .map(std::num::NonZeroUsize::get)
91            .unwrap_or(1);
92        self.resolved_scan_threads_with(
93            available,
94            std::env::var("RAYON_NUM_THREADS").ok().as_deref(),
95        )
96    }
97
98    fn resolved_scan_threads_with(&self, available: usize, rayon_env: Option<&str>) -> usize {
99        let available = available.max(1);
100        let requested = if self.scan_threads > 0 {
101            self.scan_threads
102        } else {
103            rayon_env
104                .and_then(|value| value.parse::<usize>().ok())
105                .filter(|value| *value > 0)
106                .unwrap_or_else(|| available.min(2))
107        };
108        requested.clamp(1, available)
109    }
110}
111
112/// `[general]` — settings shared across subcommands.
113#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
114pub struct GeneralConfig {
115    /// Default time range when no --daily/--weekly/--monthly/--all flag is given.
116    /// One of: "daily" | "weekly" | "monthly" | "all".
117    #[serde(default)]
118    pub default_time_range: TimeRange,
119    /// Check for a newer vct release when the CLI starts.
120    #[serde(default = "default_true")]
121    pub auto_update: bool,
122    /// Layout version of this file, stamped by vct. Only the upgrade pass reads
123    /// it; leave it alone unless you want a past upgrade to run again.
124    #[serde(
125        default = "legacy_config_version",
126        deserialize_with = "de_config_version"
127    )]
128    pub version: u32,
129}
130
131impl Default for GeneralConfig {
132    fn default() -> Self {
133        Self {
134            default_time_range: TimeRange::default(),
135            auto_update: true,
136            // A file this tool writes is current by construction; only a file
137            // read from disk without the key is legacy (see the serde default).
138            version: CONFIG_VERSION,
139        }
140    }
141}
142
143/// `[usage]` — usage dashboard preferences.
144#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
145pub struct UsageConfig {
146    /// Start the usage dashboard with models merged across provider prefixes.
147    /// Toggled live with `m`; the last state is saved back here.
148    #[serde(default)]
149    pub merge_models: bool,
150    /// Seconds between automatic redraws of the usage TUI (minimum 1).
151    #[serde(default = "default_refresh_secs")]
152    pub refresh_interval: u64,
153    /// Live quota-panel preferences.
154    #[serde(default)]
155    pub quota: QuotaConfig,
156}
157
158impl Default for UsageConfig {
159    fn default() -> Self {
160        Self {
161            merge_models: false,
162            refresh_interval: default_refresh_secs(),
163            quota: QuotaConfig::default(),
164        }
165    }
166}
167
168impl UsageConfig {
169    /// The usage TUI redraw cadence, clamped to a sane minimum so a `0` cannot
170    /// busy-loop.
171    pub fn refresh_secs(&self) -> u64 {
172        self.refresh_interval.max(1)
173    }
174
175    /// The live quota-panel poll cadence, clamped to a sane minimum.
176    pub fn quota_refresh_secs(&self) -> u64 {
177        self.quota.refresh_interval.max(1)
178    }
179
180    /// Whether the quota panel for `provider` is enabled (case-insensitive).
181    pub fn shows_quota_panel(&self, provider: &str) -> bool {
182        quota_panel_selected(&self.quota.panels, provider)
183    }
184}
185
186/// `[usage.quota]` — live quota-panel preferences.
187#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
188pub struct QuotaConfig {
189    /// Which live quota panels to show in the usage TUI (by provider name:
190    /// `claude` / `codex` / `copilot` / `cursor` / `grok`). An empty list hides
191    /// every quota card; the Provider Usage pane has its own `p` toggle.
192    #[serde(default = "default_quota_panels")]
193    pub panels: Vec<String>,
194    /// Seconds between live quota-panel polls, shared by every provider
195    /// (minimum 1). Higher is safer against a provider's rate limits.
196    #[serde(default = "default_quota_refresh_secs")]
197    pub refresh_interval: u64,
198}
199
200impl Default for QuotaConfig {
201    fn default() -> Self {
202        Self {
203            panels: default_quota_panels(),
204            refresh_interval: default_quota_refresh_secs(),
205        }
206    }
207}
208
209/// Whether `name` is one of the selected quota-panel names (case-insensitive).
210///
211/// The single home for the panel-selection rule, shared by
212/// [`UsageConfig::shows_quota_panel`] and the usage TUI's panel masking.
213pub fn quota_panel_selected(panels: &[String], name: &str) -> bool {
214    panels.iter().any(|p| p.eq_ignore_ascii_case(name))
215}
216
217/// `[analysis]` — analysis dashboard preferences.
218#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
219pub struct AnalysisConfig {
220    /// Seconds between automatic redraws of the analysis TUI (minimum 1).
221    #[serde(default = "default_refresh_secs")]
222    pub refresh_interval: u64,
223}
224
225impl Default for AnalysisConfig {
226    fn default() -> Self {
227        Self {
228            refresh_interval: default_refresh_secs(),
229        }
230    }
231}
232
233impl AnalysisConfig {
234    /// The refresh cadence, clamped to a sane minimum so a `0` cannot busy-loop.
235    pub fn refresh_secs(&self) -> u64 {
236        self.refresh_interval.max(1)
237    }
238}
239
240/// `[providers]` — per-provider include toggles.
241///
242/// Each provider defaults to `true`; setting one to `false` skips it entirely
243/// (no directory scan, no API) in both the usage and analysis roll-ups.
244#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
245pub struct ProvidersConfig {
246    #[serde(default = "default_true")]
247    pub claude: bool,
248    #[serde(default = "default_true")]
249    pub codex: bool,
250    #[serde(default = "default_true")]
251    pub copilot: bool,
252    #[serde(default = "default_true")]
253    pub gemini: bool,
254    #[serde(default = "default_true")]
255    pub opencode: bool,
256    #[serde(default = "default_true")]
257    pub cursor: bool,
258    #[serde(default = "default_true")]
259    pub hermes: bool,
260    #[serde(default = "default_true")]
261    pub grok: bool,
262}
263
264impl Default for ProvidersConfig {
265    fn default() -> Self {
266        Self {
267            claude: true,
268            codex: true,
269            copilot: true,
270            gemini: true,
271            opencode: true,
272            cursor: true,
273            hermes: true,
274            grok: true,
275        }
276    }
277}
278
279/// `[logging]` — file logging preferences.
280///
281/// Diagnostics are written to `~/.vct/logs/vct-YYYY-MM-DD.log` (plain text, one
282/// line per record). Nothing is ever printed to the terminal, so the TUI is
283/// never disturbed. The log file is created lazily on the first record, so a
284/// command that logs nothing never touches `~/.vct`.
285#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
286pub struct LoggingConfig {
287    /// Minimum level written to the log file.
288    /// One of: "off" | "error" | "warn" | "info" | "debug" | "trace".
289    #[serde(default)]
290    pub level: LogLevel,
291    /// Days of daily log files to keep; older `vct-*.log` files are pruned on
292    /// startup. `0` keeps every file.
293    #[serde(default = "default_log_retention_days")]
294    pub retention_days: u32,
295}
296
297impl Default for LoggingConfig {
298    fn default() -> Self {
299        Self {
300            level: LogLevel::default(),
301            retention_days: default_log_retention_days(),
302        }
303    }
304}
305
306/// Minimum severity written to the log file.
307#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
308#[serde(rename_all = "lowercase")]
309pub enum LogLevel {
310    /// Disable file logging entirely.
311    Off,
312    /// Only errors.
313    Error,
314    /// Errors and warnings (the default — quiet when healthy, records problems).
315    #[default]
316    Warn,
317    /// Adds high-level informational breadcrumbs.
318    Info,
319    /// Adds verbose diagnostics (per-file parse skips, quota poll detail).
320    Debug,
321    /// Everything, including cache-hit traces.
322    Trace,
323}
324
325fn default_true() -> bool {
326    true
327}
328
329fn default_log_retention_days() -> u32 {
330    7
331}
332
333fn default_refresh_secs() -> u64 {
334    10
335}
336
337fn default_quota_refresh_secs() -> u64 {
338    60
339}
340
341/// The version a file carrying no `[general].version` key predates.
342fn legacy_config_version() -> u32 {
343    1
344}
345
346/// Reads the version marker, mapping a hand-edited non-integer (`version = "2"`)
347/// onto the legacy value instead of rejecting it.
348///
349/// Rejection would fail the whole document, and the infallible read then turns
350/// that into a silent reset of every setting — the same trap
351/// [`rename_refresh_key`] drops malformed values to avoid. Reading it as legacy
352/// makes the upgrade pass rewrite the key with a real integer.
353fn de_config_version<'de, D>(deserializer: D) -> Result<u32, D::Error>
354where
355    D: serde::Deserializer<'de>,
356{
357    Ok(Value::deserialize(deserializer)
358        .ok()
359        .and_then(|v| v.as_u64())
360        .and_then(|n| u32::try_from(n).ok())
361        .unwrap_or_else(legacy_config_version))
362}
363
364fn default_quota_panels() -> Vec<String> {
365    ["claude", "codex", "copilot", "cursor", "grok"]
366        .iter()
367        .map(|s| s.to_string())
368        .collect()
369}
370
371/// Loads settings from `~/.vct/config.toml`, creating it with defaults on first
372/// run.
373///
374/// Infallible: any error resolving, reading, or parsing degrades to
375/// [`Config::default`].
376pub fn load() -> Config {
377    match get_cache_dir() {
378        Ok(dir) => load_in(&dir),
379        Err(_) => Config::default(),
380    }
381}
382
383/// Reads settings from `~/.vct/config.toml` without creating or modifying any
384/// files. Legacy layouts are migrated in memory only.
385///
386/// Infallible: any error resolving, reading, or parsing degrades to
387/// [`Config::default`].
388pub fn load_read_only() -> Config {
389    match resolve_paths() {
390        Ok(paths) => load_read_only_in(&paths.cache_dir),
391        Err(_) => Config::default(),
392    }
393}
394
395/// [`load_read_only`] rooted at an explicit directory (test seam).
396pub fn load_read_only_in(dir: &Path) -> Config {
397    let path = dir.join("config.toml");
398    let Ok(text) = std::fs::read_to_string(path) else {
399        return Config::default();
400    };
401    parse_config_text(&text)
402}
403
404/// [`load`] rooted at an explicit directory (test seam).
405pub fn load_in(dir: &Path) -> Config {
406    let path = dir.join("config.toml");
407    if path.exists() {
408        let Ok(text) = std::fs::read_to_string(&path) else {
409            return Config::default();
410        };
411        // Auto-migrate a legacy-format file in place so existing users pick up the
412        // renamed keys, the `[usage.quota]` nesting, and the `#:schema` directive.
413        // Best-effort: a failed write (e.g. a read-only home) still yields a
414        // correct in-memory Config below. Malformed TOML is never overwritten.
415        let effective = match migrate_text(&text) {
416            Ok(Some(migrated)) => {
417                let _ = write_string_atomic(&path, &migrated);
418                migrated
419            }
420            Ok(None) => text,
421            Err(_) => return Config::default(),
422        };
423        return parse_effective_config(&effective);
424    }
425    // First run: materialize the generated commented template.
426    let text = default_document().to_string();
427    let _ = write_string_atomic(&path, &text);
428    toml_edit::de::from_str(&text).unwrap_or_default()
429}
430
431/// Parses text after the same in-memory structural migration used by the
432/// writable loader, without persisting the result.
433fn parse_config_text(text: &str) -> Config {
434    match migrate_text(text) {
435        Ok(Some(migrated)) => parse_effective_config(&migrated),
436        Ok(None) => parse_effective_config(text),
437        Err(_) => Config::default(),
438    }
439}
440
441fn parse_effective_config(text: &str) -> Config {
442    let mut config: Config = toml_edit::de::from_str(text).unwrap_or_default();
443    // Backstop for any residual legacy form the structural migration leaves
444    // alone (e.g. a hand-edited inline table).
445    migrate_legacy(&mut config, text);
446    config
447}
448
449/// Outcome of an explicit [`migrate_config_file`] run, surfaced by
450/// `vct config migrate`.
451#[derive(Debug, Clone, Copy, PartialEq, Eq)]
452pub enum MigrationStatus {
453    /// No file existed, so a fresh commented default was written.
454    Created,
455    /// A legacy-format file was rewritten to the current on-disk format.
456    Migrated,
457    /// The file was already current; nothing was written.
458    AlreadyCurrent,
459}
460
461/// Migrates the `config.toml` at `path` to the current on-disk format in place,
462/// creating the commented default when the file is absent. Shared by
463/// `vct config migrate` and the auto-migration in [`load_in`].
464pub fn migrate_config_file(path: &Path) -> Result<MigrationStatus> {
465    if !path.exists() {
466        write_string_atomic(path, &default_document().to_string())?;
467        return Ok(MigrationStatus::Created);
468    }
469    let text = std::fs::read_to_string(path)?;
470    match migrate_text(&text)? {
471        Some(migrated) => {
472            write_string_atomic(path, &migrated)?;
473            Ok(MigrationStatus::Migrated)
474        }
475        None => Ok(MigrationStatus::AlreadyCurrent),
476    }
477}
478
479/// Applies the structural migration to raw config text: `Ok(Some(new))` when it
480/// changed, `Ok(None)` when already current, `Err` when the text is not valid
481/// TOML (so a caller never overwrites an unparseable file with defaults).
482pub fn migrate_text(text: &str) -> Result<Option<String>> {
483    let mut doc: DocumentMut = text.parse()?;
484    Ok(migrate_document(&mut doc).then(|| doc.to_string()))
485}
486
487/// Read-time backstop for config files written before quota settings moved into
488/// `[usage.quota]`: an old `[usage].quota_panels` key is honored when the new
489/// `[usage.quota].panels` key is absent, and a legacy `refresh_interval_secs` is
490/// mapped onto `refresh_interval`. The on-disk file is normally rewritten by
491/// [`migrate_document`] first; this shim only has to cover the forms that pass
492/// leaves alone (e.g. an inline `usage = { ... }` table), keeping the in-memory
493/// [`Config`] correct even when the file itself was not upgraded.
494fn migrate_legacy(config: &mut Config, raw: &str) {
495    let Ok(doc) = raw.parse::<DocumentMut>() else {
496        return;
497    };
498    if let Some(usage) = doc.get("usage").and_then(Item::as_table_like) {
499        // [usage].quota_panels -> [usage.quota].panels. A present new key (even
500        // `panels = []`) always wins over the legacy one.
501        let has_new_panels = usage
502            .get("quota")
503            .and_then(Item::as_table_like)
504            .is_some_and(|q| q.contains_key("panels"));
505        if !has_new_panels && let Some(legacy) = usage.get("quota_panels").and_then(Item::as_array)
506        {
507            config.usage.quota.panels = legacy
508                .iter()
509                .filter_map(|v| v.as_str().map(str::to_string))
510                .collect();
511        }
512        migrate_refresh_secs(usage, &mut config.usage.refresh_interval);
513    }
514    if let Some(analysis) = doc.get("analysis").and_then(Item::as_table_like) {
515        migrate_refresh_secs(analysis, &mut config.analysis.refresh_interval);
516    }
517}
518
519/// Honors a legacy `refresh_interval_secs` when the new `refresh_interval` key
520/// is absent from the section. This replaces a serde `alias`, which would make
521/// serde reject a mid-upgrade file carrying *both* names as a duplicate field
522/// and (via the infallible read) silently reset the whole config to defaults.
523fn migrate_refresh_secs(section: &dyn TableLike, target: &mut u64) {
524    if !section.contains_key("refresh_interval")
525        && let Some(secs) = section
526            .get("refresh_interval_secs")
527            .and_then(Item::as_integer)
528        && let Ok(v) = u64::try_from(secs)
529    {
530        *target = v;
531    }
532}
533
534/// Structural on-disk migration for a `config.toml` written by an older vct.
535///
536/// Operates on standard `[header]` tables only — an inline `usage = { ... }`
537/// table is left to the read-time [`migrate_legacy`] shim, so this never has to
538/// synthesize a header table inside an inline one. Returns whether anything
539/// changed, so the caller rewrites the file at most once. Idempotent:
540/// re-running on an already-migrated document returns `false`.
541fn migrate_document(doc: &mut DocumentMut) -> bool {
542    let schema = json_schema();
543    let mut changed = false;
544
545    // `[usage]`: rename the refresh key, then move quota_panels into the nested
546    // `[usage.quota]`. Quota goes last so its `[usage.quota]` header renders
547    // after every leaf key of `[usage]` (a leaf after a sub-table would be
548    // invalid TOML).
549    if doc.get("usage").is_some_and(Item::is_table) {
550        if let Some(usage) = doc.get_mut("usage").and_then(Item::as_table_mut) {
551            changed |= rename_refresh_key(usage, &schema, &["usage", "refresh_interval"]);
552        }
553        changed |= migrate_quota_panels(doc, &schema);
554    }
555
556    // `[analysis]`: rename the refresh key.
557    if let Some(analysis) = doc.get_mut("analysis").and_then(Item::as_table_mut) {
558        changed |= rename_refresh_key(analysis, &schema, &["analysis", "refresh_interval"]);
559    }
560
561    // Runs after the moves above so it sees the panel list they may have just
562    // relocated into `[usage.quota]`.
563    changed |= backfill_new_quota_panels(doc, &schema);
564
565    // The `#:schema` directive on the first line drives editor autocomplete. It
566    // attaches to the document's first element, so it goes last: on an empty
567    // file the back-fill above is what creates that first element, and running
568    // earlier would leave the directive to a second pass.
569    if !has_schema_directive(doc) {
570        changed |= prepend_schema_directive(doc);
571    }
572
573    changed
574}
575
576/// Adds quota panels that shipped after this file was written, exactly once.
577///
578/// The one-shot guarantee is `[general].version`: a file predating a panel is
579/// missing it because the panel did not exist yet, not because the user removed
580/// it — but only until the marker is stamped, after which a removal sticks.
581/// Only the panels a version *added* are considered, so a panel the user
582/// dropped before this upgrade is never resurrected either.
583///
584/// Two cases are deliberately left alone: an empty `panels` list (the band was
585/// turned off on purpose) and a file whose `[general]` is an inline table (the
586/// marker could not be written, so back-filling could not stay one-shot).
587fn backfill_new_quota_panels(doc: &mut DocumentMut, schema: &Value) -> bool {
588    let from_version = match doc.get("general") {
589        Some(item) => {
590            let Some(general) = item.as_table() else {
591                // Inline `general = { ... }`: no place to stamp the marker.
592                return false;
593            };
594            general
595                .get("version")
596                .and_then(Item::as_integer)
597                .and_then(|v| u32::try_from(v).ok())
598                .unwrap_or_else(legacy_config_version)
599        }
600        None => legacy_config_version(),
601    };
602    if from_version >= CONFIG_VERSION {
603        return false;
604    }
605
606    // The list lives inside an inline table this pass never rewrites, so it
607    // cannot be back-filled — and stamping the marker anyway would retire the
608    // upgrade without having run it.
609    if panels_out_of_reach(doc) {
610        // This upgrade may still safely add an unrelated leaf to a standard
611        // `[general]` table. Do not stamp the version though: the quota upgrade
612        // has not run and must remain eligible for a future safe migration.
613        return backfill_auto_update(doc, schema);
614    }
615
616    let mut changed = false;
617    if let Some(panels) = doc
618        .get_mut("usage")
619        .and_then(Item::as_table_mut)
620        .and_then(|usage| usage.get_mut("quota"))
621        .and_then(Item::as_table_mut)
622        .and_then(|quota| quota.get_mut("panels"))
623        .and_then(Item::as_array_mut)
624        // An empty list hides the whole band; adding to it would undo that.
625        .filter(|panels| !panels.is_empty())
626    {
627        let missing: Vec<&str> = PANELS_ADDED_BY_VERSION
628            .iter()
629            .filter(|(version, _)| from_version < *version)
630            .map(|(_, name)| *name)
631            .filter(|name| {
632                !panels
633                    .iter()
634                    .filter_map(|p| p.as_str())
635                    .any(|p| p.eq_ignore_ascii_case(name))
636            })
637            .collect();
638        for name in missing {
639            panels.push(name);
640            changed = true;
641        }
642    }
643
644    changed |= stamp_config_version(doc, schema);
645    changed |= backfill_auto_update(doc, schema);
646    changed
647}
648
649/// Adds the startup update preference to a legacy standard `[general]` table.
650///
651/// Inline tables are deliberately left untouched: changing their shape here
652/// risks discarding hand-edited formatting or sibling keys. Serde supplies the
653/// same `true` default when such a table is read.
654fn backfill_auto_update(doc: &mut DocumentMut, schema: &Value) -> bool {
655    let Some(general) = doc.get_mut("general").and_then(Item::as_table_mut) else {
656        return false;
657    };
658    if !general.contains_key("auto_update") {
659        general.insert("auto_update", value(true));
660        apply_comment(
661            general,
662            "auto_update",
663            "",
664            schema,
665            &["general", "auto_update"],
666        );
667        return true;
668    }
669    false
670}
671
672/// Whether the effective panel list sits inside an inline table.
673///
674/// [`migrate_document`] deliberately leaves inline tables alone, so such a list
675/// can be neither read nor extended here — the read-time [`migrate_legacy`] shim
676/// is what keeps it working. A file without a panel list at all is *not* out of
677/// reach: it simply picks up the current default, new names included.
678fn panels_out_of_reach(doc: &DocumentMut) -> bool {
679    let Some(usage) = doc.get("usage") else {
680        return false;
681    };
682    let holds_panels = |table: &dyn TableLike| {
683        table.contains_key("quota_panels")
684            || table
685                .get("quota")
686                .and_then(Item::as_table_like)
687                .is_some_and(|quota| quota.contains_key("panels"))
688    };
689    match usage.as_table() {
690        // `usage = { ... }`: nothing under it can be rewritten.
691        None => usage.as_table_like().is_some_and(holds_panels),
692        Some(usage) => {
693            // A standard `[usage]` whose `quota` child is inline.
694            let inline_quota = usage
695                .get("quota")
696                .filter(|quota| !quota.is_table())
697                .and_then(Item::as_table_like)
698                .is_some_and(|quota| quota.contains_key("panels"));
699            // Or a legacy `quota_panels` that `migrate_quota_panels` declined to
700            // move (it also refuses an inline `quota` sibling), which leaves the
701            // effective list somewhere this pass cannot extend.
702            inline_quota || usage.contains_key("quota_panels")
703        }
704    }
705}
706
707/// Writes `[general].version` (creating the table when a hand-written file has
708/// no `[general]` section at all).
709fn stamp_config_version(doc: &mut DocumentMut, schema: &Value) -> bool {
710    let mut changed = false;
711    if doc.get("general").is_none() {
712        let mut table = Table::new();
713        table.set_implicit(false);
714        if let Some(desc) = schema_description(schema, &["general"]) {
715            table
716                .decor_mut()
717                .set_prefix(format!("\n{}", comment_block(&desc)));
718        }
719        doc.insert("general", Item::Table(table));
720        changed = true;
721    }
722    let Some(general) = doc.get_mut("general").and_then(Item::as_table_mut) else {
723        return changed;
724    };
725    let had_key = general.contains_key("version");
726    let version_is_current = general
727        .get("version")
728        .and_then(Item::as_integer)
729        .is_some_and(|version| version == i64::from(CONFIG_VERSION));
730    general.insert("version", value(i64::from(CONFIG_VERSION)));
731    if !had_key {
732        apply_comment(general, "version", "", schema, &["general", "version"]);
733    }
734    changed || !version_is_current
735}
736
737/// Whether the document already carries a `#:schema` directive line.
738fn has_schema_directive(doc: &DocumentMut) -> bool {
739    // Only the first non-blank line can be a real directive; scanning every line
740    // would false-positive on a `#:schema`-prefixed line buried inside a
741    // multi-line string value or a later comment, wrongly skipping the prepend.
742    doc.to_string()
743        .lines()
744        .find(|line| !line.trim().is_empty())
745        .is_some_and(|line| line.trim_start().starts_with("#:schema"))
746}
747
748/// Prepends the `#:schema` directive to the document's leading trivia (the
749/// prefix decor of its first element). Returns whether it was added.
750fn prepend_schema_directive(doc: &mut DocumentMut) -> bool {
751    let directive = format!("#:schema {SCHEMA_URL}\n");
752    let root = doc.as_table_mut();
753    let Some(first_key) = root.iter().next().map(|(k, _)| k.to_string()) else {
754        return false;
755    };
756    if let Some(item) = root.get_mut(&first_key)
757        && let Some(table) = item.as_table_mut()
758    {
759        let existing = decor_prefix(table.decor());
760        table
761            .decor_mut()
762            .set_prefix(format!("{directive}{existing}"));
763        return true;
764    }
765    if let Some(mut km) = root.key_mut(&first_key) {
766        let existing = decor_prefix(km.leaf_decor());
767        km.leaf_decor_mut()
768            .set_prefix(format!("{directive}{existing}"));
769        return true;
770    }
771    false
772}
773
774/// The existing prefix text of a decor, or an empty string.
775fn decor_prefix(decor: &toml_edit::Decor) -> String {
776    decor
777        .prefix()
778        .and_then(|p| p.as_str())
779        .unwrap_or("")
780        .to_string()
781}
782
783/// Within `table`, migrate a legacy `refresh_interval_secs` to the current
784/// `refresh_interval`: rename when only the old key is present, or drop the old
785/// key when both coexist (a mid-upgrade file where the new key already wins).
786///
787/// A malformed legacy value (negative, float, string) is **dropped**, not
788/// promoted: writing it into the typed `refresh_interval` field would make serde
789/// reject the whole migrated file and silently reset every setting to defaults.
790/// Dropping it lets the typed default apply, matching the pre-migration read.
791fn rename_refresh_key(table: &mut Table, schema: &Value, comment_path: &[&str]) -> bool {
792    if !table.contains_key("refresh_interval_secs") {
793        return false;
794    }
795    // Both present: the new key already wins on read, so just drop the stale one.
796    if table.contains_key("refresh_interval") {
797        table.remove("refresh_interval_secs");
798        return true;
799    }
800    let Some((old_key, item)) = table.remove_entry("refresh_interval_secs") else {
801        return false;
802    };
803    if let Some(secs) = item.as_integer().and_then(|n| u64::try_from(n).ok()) {
804        table.insert("refresh_interval", value(secs as i64));
805        // Keep a hand-added comment; otherwise apply the current schema comment.
806        let existing = decor_prefix(old_key.leaf_decor());
807        apply_comment(table, "refresh_interval", &existing, schema, comment_path);
808    }
809    true
810}
811
812/// Moves a legacy top-level `[usage].quota_panels` array into the nested
813/// `[usage.quota].panels`, creating the `[usage.quota]` table (with the current
814/// default `refresh_interval`) when needed. A present `[usage.quota].panels`
815/// wins; the legacy key is removed once handled. An inline `quota = { ... }`
816/// child is left untouched (legacy key preserved) so no data is lost trying to
817/// merge into an inline table.
818fn migrate_quota_panels(doc: &mut DocumentMut, schema: &Value) -> bool {
819    let Some(usage) = doc.get("usage").and_then(Item::as_table) else {
820        return false;
821    };
822    if !usage.contains_key("quota_panels") {
823        return false;
824    }
825    if usage.get("quota").is_some_and(Item::is_inline_table) {
826        return false;
827    }
828    let has_new_panels = usage
829        .get("quota")
830        .and_then(Item::as_table_like)
831        .is_some_and(|q| q.contains_key("panels"));
832
833    let usage = doc
834        .get_mut("usage")
835        .and_then(Item::as_table_mut)
836        .expect("usage table present");
837    let Some((old_key, old_item)) = usage.remove_entry("quota_panels") else {
838        return false;
839    };
840    if has_new_panels {
841        // The new `[usage.quota].panels` already wins; drop the legacy key only.
842        return true;
843    }
844    let legacy_panels: Vec<String> = old_item
845        .as_array()
846        .map(|a| {
847            a.iter()
848                .filter_map(|v| v.as_str().map(str::to_string))
849                .collect()
850        })
851        .unwrap_or_default();
852    // Carry the legacy key's comment onto the new `panels` key.
853    let comment = decor_prefix(old_key.leaf_decor());
854
855    if usage.get("quota").and_then(Item::as_table).is_some() {
856        // Existing `[usage.quota]` without a panels key: just add it.
857        let quota = usage
858            .get_mut("quota")
859            .and_then(Item::as_table_mut)
860            .expect("quota table present");
861        quota.insert("panels", value(panels_array(&legacy_panels)));
862        apply_comment(
863            quota,
864            "panels",
865            &comment,
866            schema,
867            &["usage", "quota", "panels"],
868        );
869    } else {
870        usage.insert(
871            "quota",
872            Item::Table(build_quota_table(&legacy_panels, &comment, schema)),
873        );
874    }
875    true
876}
877
878/// A TOML array from a list of panel names.
879fn panels_array(panels: &[String]) -> Array {
880    panels.iter().map(String::as_str).collect()
881}
882
883/// Builds a fresh `[usage.quota]` table from the migrated panels (carrying the
884/// legacy key's comment when it had one) and the current default
885/// `refresh_interval`, which is brand-new so it gets the schema comment.
886fn build_quota_table(panels: &[String], panels_comment: &str, schema: &Value) -> Table {
887    let mut table = Table::new();
888    table.set_implicit(false);
889    if let Some(desc) = schema_description(schema, &["usage", "quota"]) {
890        table
891            .decor_mut()
892            .set_prefix(format!("\n{}", comment_block(&desc)));
893    }
894    table.insert("panels", value(panels_array(panels)));
895    apply_comment(
896        &mut table,
897        "panels",
898        panels_comment,
899        schema,
900        &["usage", "quota", "panels"],
901    );
902    table.insert(
903        "refresh_interval",
904        value(default_quota_refresh_secs() as i64),
905    );
906    apply_comment(
907        &mut table,
908        "refresh_interval",
909        "",
910        schema,
911        &["usage", "quota", "refresh_interval"],
912    );
913    table
914}
915
916/// Attaches a leading `#` comment to `key`, mirroring the leaf styling in
917/// [`annotate_table`]: keeps a non-empty `existing` comment (a hand-added or
918/// carried-over one), otherwise falls back to the field's schema `description`.
919fn apply_comment(table: &mut Table, key: &str, existing: &str, schema: &Value, path: &[&str]) {
920    let prefix = if existing.trim().is_empty() {
921        schema_description(schema, path).map(|desc| format!("\n{}", comment_block(&desc)))
922    } else {
923        Some(existing.to_string())
924    };
925    if let Some(prefix) = prefix
926        && let Some(mut km) = table.key_mut(key)
927    {
928        km.leaf_decor_mut().set_prefix(prefix);
929    }
930}
931
932/// Walks the (inlined) JSON schema to a nested field's `description`.
933fn schema_description(schema: &Value, path: &[&str]) -> Option<String> {
934    let mut cur = schema;
935    for key in path {
936        cur = cur.get("properties")?.get(key)?;
937    }
938    cur.get("description")
939        .and_then(Value::as_str)
940        .map(str::to_string)
941}
942
943/// Persists the usage dashboard's merge toggle back to the config.
944pub fn save_merge_models(enabled: bool) -> Result<()> {
945    save_merge_models_in(&get_cache_dir()?, enabled)
946}
947
948/// [`save_merge_models`] rooted at an explicit directory (test seam).
949pub fn save_merge_models_in(dir: &Path, enabled: bool) -> Result<()> {
950    edit_in(dir, |doc| {
951        // A hand-edited file could make `usage` a scalar (`usage = "bad"`);
952        // replace any non-table-like value with an empty table so indexing into
953        // it below cannot panic. `is_table_like` accepts both the `[usage]`
954        // header form AND an inline table (`usage = { ... }`), so a valid config
955        // in either form is left intact (its keys/comments preserved) — only a
956        // genuine scalar is repaired.
957        if !doc["usage"].is_table_like() {
958            doc["usage"] = Item::Table(Table::new());
959        }
960        doc["usage"]["merge_models"] = value(enabled);
961    })
962}
963
964/// Reads the current document (or the template when absent/malformed), applies
965/// `mutate`, and writes it back atomically — preserving formatting and comments.
966fn edit_in(dir: &Path, mutate: impl FnOnce(&mut DocumentMut)) -> Result<()> {
967    let path = dir.join("config.toml");
968    let mut doc = std::fs::read_to_string(&path)
969        .ok()
970        .and_then(|text| text.parse::<DocumentMut>().ok())
971        .unwrap_or_else(default_document);
972    mutate(&mut doc);
973    write_string_atomic(&path, &doc.to_string())
974}
975
976/// The JSON schema for the settings file, used both by `vct config schema` and
977/// to derive the commented default template. Subschemas are inlined so the
978/// document is self-contained and each field's `description` is easy to walk.
979pub fn json_schema() -> Value {
980    let settings =
981        schemars::generate::SchemaSettings::draft2020_12().with(|s| s.inline_subschemas = true);
982    let schema = settings.into_generator().into_root_schema_for::<Config>();
983    serde_json::to_value(&schema).expect("config schema serializes to JSON")
984}
985
986/// The pretty-printed JSON schema (the committed `vct.schema.json` body, also
987/// what `vct config schema` prints). Ends with a trailing newline.
988pub fn schema_json() -> String {
989    let mut s = serde_json::to_string_pretty(&json_schema()).expect("schema serializes");
990    s.push('\n');
991    s
992}
993
994/// Builds the commented default `config.toml` document from the typed
995/// [`Config`]: values come from [`Config::default`] (serialized via `toml_edit`),
996/// per-key and per-section comments come from the schema `description`s, and the
997/// `#:schema` directive is placed on the first line.
998fn default_document() -> DocumentMut {
999    let mut doc: DocumentMut = toml_edit::ser::to_string(&Config::default())
1000        .expect("default config serializes to TOML")
1001        .parse()
1002        .expect("serialized default config is valid TOML");
1003    let schema = json_schema();
1004    annotate_table(doc.as_table_mut(), &schema, true);
1005    doc
1006}
1007
1008/// Recursively attaches each field's schema `description` as a leading `#`
1009/// comment to the matching key or sub-table of `table`. On the root table the
1010/// `#:schema` directive is prepended to the first section.
1011fn annotate_table(table: &mut Table, schema: &Value, is_root: bool) {
1012    let props = schema
1013        .get("properties")
1014        .and_then(Value::as_object)
1015        .cloned()
1016        .unwrap_or_default();
1017    let keys: Vec<String> = table.iter().map(|(k, _)| k.to_string()).collect();
1018    for (idx, key) in keys.iter().enumerate() {
1019        let field_schema = props.get(key);
1020        let desc = field_schema
1021            .and_then(|s| s.get("description"))
1022            .and_then(Value::as_str);
1023        let is_table = table
1024            .get(key)
1025            .is_some_and(|i| i.is_table() || i.is_inline_table());
1026        if is_table {
1027            let field_schema = field_schema.cloned();
1028            // The serializer suffixes every key with a space (right for a leaf's
1029            // `k = v`, but it renders a table header as `[usage ]`); trim it.
1030            if let Some(mut km) = table.key_mut(key) {
1031                km.leaf_decor_mut().set_suffix("");
1032            }
1033            if let Some(item) = table.get_mut(key)
1034                && let Some(sub) = as_standard_table(item)
1035            {
1036                let mut prefix = String::new();
1037                if is_root && idx == 0 {
1038                    prefix.push_str(&format!("#:schema {SCHEMA_URL}\n\n"));
1039                } else {
1040                    prefix.push('\n');
1041                }
1042                if let Some(d) = desc {
1043                    prefix.push_str(&comment_block(d));
1044                }
1045                sub.decor_mut().set_prefix(prefix);
1046                if let Some(fs) = field_schema {
1047                    annotate_table(sub, &fs, false);
1048                }
1049            }
1050        } else if let Some(d) = desc
1051            && let Some(mut km) = table.key_mut(key)
1052        {
1053            km.leaf_decor_mut()
1054                .set_prefix(format!("\n{}", comment_block(d)));
1055        }
1056    }
1057}
1058
1059/// Coerces `item` to a standard `[header]` table, converting an inline table
1060/// (`{ ... }`) in place so a leading comment can be attached to its header.
1061fn as_standard_table(item: &mut Item) -> Option<&mut Table> {
1062    if item.is_inline_table()
1063        && let Some(inline) = item.as_inline_table().cloned()
1064    {
1065        *item = Item::Table(inline.into_table());
1066    }
1067    item.as_table_mut()
1068}
1069
1070/// Renders a schema description as one or more `# ` comment lines (each ending
1071/// in a newline), so a multi-line description becomes multi-line comments.
1072fn comment_block(desc: &str) -> String {
1073    desc.lines()
1074        .map(|line| {
1075            if line.is_empty() {
1076                "#\n".to_string()
1077            } else {
1078                format!("# {line}\n")
1079            }
1080        })
1081        .collect()
1082}
1083
1084#[cfg(test)]
1085mod tests {
1086    use super::*;
1087
1088    #[test]
1089    fn generated_template_parses_to_expected_defaults() {
1090        let text = default_document().to_string();
1091        let cfg: Config = toml_edit::de::from_str(&text).unwrap();
1092        assert_eq!(cfg, Config::default());
1093        assert!(text.starts_with(&format!("#:schema {SCHEMA_URL}")));
1094        assert_eq!(cfg.general.default_time_range, TimeRange::All);
1095        assert_eq!(cfg.usage.quota.panels, default_quota_panels());
1096        assert!(cfg.usage.shows_quota_panel("cursor"));
1097        assert!(!cfg.usage.merge_models);
1098        assert_eq!(cfg.usage.refresh_interval, 10);
1099        assert_eq!(cfg.usage.quota.refresh_interval, 60);
1100        assert_eq!(cfg.analysis.refresh_interval, 10);
1101        assert_eq!(cfg.performance.scan_threads, 0);
1102        assert_eq!(cfg.providers, ProvidersConfig::default());
1103        assert!(cfg.providers.cursor);
1104        assert!(cfg.providers.grok);
1105        assert_eq!(cfg.logging.level, LogLevel::Warn);
1106        assert_eq!(cfg.logging.retention_days, 7);
1107    }
1108
1109    #[test]
1110    fn missing_sections_use_defaults() {
1111        // An empty file must still default the opt-out settings, which only holds
1112        // because the section structs impl Default by hand.
1113        let cfg: Config = toml_edit::de::from_str("").unwrap();
1114        assert_eq!(cfg.usage.quota.panels, default_quota_panels());
1115        assert!(cfg.providers.cursor);
1116        assert!(cfg.providers.grok);
1117        assert_eq!(cfg.usage.refresh_secs(), 10);
1118        assert_eq!(cfg.usage.quota_refresh_secs(), 60);
1119        // A file with no [logging] section backfills the whole section default,
1120        // which is what keeps the additive section migration-free.
1121        assert_eq!(cfg.logging, LoggingConfig::default());
1122        assert_eq!(cfg.performance, PerformanceConfig::default());
1123    }
1124
1125    #[test]
1126    fn scan_threads_resolve_config_then_env_then_auto() {
1127        let auto = PerformanceConfig { scan_threads: 0 };
1128        assert_eq!(auto.resolved_scan_threads_with(16, None), 2);
1129        assert_eq!(auto.resolved_scan_threads_with(1, None), 1);
1130        assert_eq!(auto.resolved_scan_threads_with(16, Some("7")), 7);
1131        assert_eq!(auto.resolved_scan_threads_with(4, Some("99")), 4);
1132        assert_eq!(auto.resolved_scan_threads_with(16, Some("0")), 2);
1133        assert_eq!(auto.resolved_scan_threads_with(16, Some("invalid")), 2);
1134
1135        let configured = PerformanceConfig { scan_threads: 12 };
1136        assert_eq!(configured.resolved_scan_threads_with(8, Some("3")), 8);
1137    }
1138
1139    #[test]
1140    fn partial_usage_section_keeps_quota_default() {
1141        let cfg: Config = toml_edit::de::from_str("[usage]\nmerge_models = true\n").unwrap();
1142        assert!(cfg.usage.merge_models);
1143        assert_eq!(cfg.usage.quota.panels, default_quota_panels());
1144        assert_eq!(cfg.usage.quota_refresh_secs(), 60);
1145    }
1146
1147    #[test]
1148    fn quota_panels_can_be_narrowed_or_emptied() {
1149        let cfg: Config =
1150            toml_edit::de::from_str("[usage.quota]\npanels = [\"claude\"]\n").unwrap();
1151        assert!(cfg.usage.shows_quota_panel("claude"));
1152        assert!(!cfg.usage.shows_quota_panel("cursor"));
1153
1154        let empty: Config = toml_edit::de::from_str("[usage.quota]\npanels = []\n").unwrap();
1155        assert!(!empty.usage.shows_quota_panel("claude"));
1156    }
1157
1158    #[test]
1159    fn refresh_interval_reads_legacy_secs() {
1160        // Old files used `refresh_interval_secs`; the migration shim maps it.
1161        let text = "[usage]\nrefresh_interval_secs = 5\n[analysis]\nrefresh_interval_secs = 7\n";
1162        let mut cfg: Config = toml_edit::de::from_str(text).unwrap();
1163        migrate_legacy(&mut cfg, text);
1164        assert_eq!(cfg.usage.refresh_secs(), 5);
1165        assert_eq!(cfg.analysis.refresh_secs(), 7);
1166    }
1167
1168    #[test]
1169    fn coexisting_refresh_keys_preserve_the_rest_of_the_config() {
1170        // A mid-upgrade file carrying BOTH the new and the legacy key must not
1171        // trip serde's duplicate-field error and reset the whole config to
1172        // defaults; parsing succeeds, the new key wins, and unrelated sections
1173        // survive.
1174        let text = "[general]\ndefault_time_range = \"weekly\"\n[usage]\nrefresh_interval = 5\nrefresh_interval_secs = 10\n[providers]\ncursor = false\n";
1175        let mut cfg: Config = toml_edit::de::from_str(text).unwrap();
1176        migrate_legacy(&mut cfg, text);
1177        assert_eq!(cfg.usage.refresh_secs(), 5);
1178        assert_eq!(cfg.general.default_time_range, TimeRange::Weekly);
1179        assert!(!cfg.providers.cursor);
1180    }
1181
1182    #[test]
1183    fn migrates_legacy_quota_panels() {
1184        // Pre-`[usage.quota]` layout: quota_panels sat directly under [usage].
1185        let text = "[usage]\nquota_panels = [\"claude\"]\n";
1186        let mut cfg: Config = toml_edit::de::from_str(text).unwrap();
1187        migrate_legacy(&mut cfg, text);
1188        assert_eq!(cfg.usage.quota.panels, vec!["claude".to_string()]);
1189        assert!(!cfg.usage.shows_quota_panel("cursor"));
1190    }
1191
1192    #[test]
1193    fn new_quota_panels_win_over_legacy() {
1194        let text = "[usage]\nquota_panels = [\"claude\"]\n[usage.quota]\npanels = []\n";
1195        let mut cfg: Config = toml_edit::de::from_str(text).unwrap();
1196        migrate_legacy(&mut cfg, text);
1197        assert!(cfg.usage.quota.panels.is_empty());
1198    }
1199
1200    #[test]
1201    fn migrate_document_upgrades_a_full_legacy_file() {
1202        let legacy = "# old header\n\n[usage]\nmerge_models = false\nquota_panels = [\"claude\", \"codex\"]\nrefresh_interval_secs = 15\n\n[analysis]\nrefresh_interval_secs = 20\n";
1203        let migrated = migrate_text(legacy).unwrap().expect("legacy file changes");
1204        // The `#:schema` directive lands on the first line.
1205        assert!(migrated.starts_with("#:schema "));
1206        // Legacy keys are gone; the current nested layout is in place.
1207        assert!(!migrated.contains("quota_panels"));
1208        assert!(!migrated.contains("refresh_interval_secs"));
1209        assert!(migrated.contains("[usage.quota]"));
1210        // The user's names keep their order; panels that shipped later are
1211        // appended once, and the file is stamped so that only happens once.
1212        assert!(migrated.contains("panels = [\"claude\", \"codex\", \"grok\"]"));
1213        let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
1214        assert_eq!(cfg.usage.refresh_interval, 15);
1215        assert_eq!(cfg.analysis.refresh_interval, 20);
1216        assert_eq!(
1217            cfg.usage.quota.panels,
1218            vec![
1219                "claude".to_string(),
1220                "codex".to_string(),
1221                "grok".to_string()
1222            ]
1223        );
1224        assert_eq!(cfg.usage.quota.refresh_interval, 60);
1225        assert_eq!(cfg.general.version, CONFIG_VERSION);
1226    }
1227
1228    #[test]
1229    fn migrate_document_is_idempotent() {
1230        let legacy = "[usage]\nquota_panels = [\"claude\"]\nrefresh_interval_secs = 15\n";
1231        let once = migrate_text(legacy).unwrap().expect("first pass changes");
1232        // A second pass over the migrated text writes nothing.
1233        assert!(migrate_text(&once).unwrap().is_none());
1234    }
1235
1236    #[test]
1237    fn migrate_document_noops_on_the_generated_default() {
1238        let current = default_document().to_string();
1239        assert!(migrate_text(&current).unwrap().is_none());
1240    }
1241
1242    #[test]
1243    fn migrate_document_adds_only_the_schema_directive_when_keys_are_current() {
1244        // Current key names, nested quota and version marker, but missing the
1245        // `#:schema` directive.
1246        let text = "[general]\nversion = 3\n\n[usage]\nrefresh_interval = 10\n\n[usage.quota]\npanels = [\"claude\"]\n";
1247        let migrated = migrate_text(text).unwrap().expect("adds #:schema");
1248        assert!(migrated.starts_with("#:schema "));
1249        assert!(migrated.contains("refresh_interval = 10"));
1250        assert!(migrated.contains("panels = [\"claude\"]"));
1251    }
1252
1253    #[test]
1254    fn migrate_document_drops_stale_refresh_key_when_both_present() {
1255        let text = "#:schema x\n[usage]\nrefresh_interval = 5\nrefresh_interval_secs = 10\n";
1256        let migrated = migrate_text(text).unwrap().expect("drops the legacy key");
1257        assert!(!migrated.contains("refresh_interval_secs"));
1258        let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
1259        assert_eq!(cfg.usage.refresh_interval, 5);
1260    }
1261
1262    #[test]
1263    fn migrate_document_leaves_an_inline_usage_table_to_the_read_shim() {
1264        // An inline `usage = { ... }` table is not restructured (only `#:schema`
1265        // is added), so nothing is lost trying to nest into an inline table.
1266        let text = "usage = { quota_panels = [\"claude\"], refresh_interval_secs = 30 }\n";
1267        let migrated = migrate_text(text).unwrap().expect("adds #:schema");
1268        assert!(migrated.starts_with("#:schema "));
1269        assert!(migrated.contains("quota_panels"));
1270        // Still idempotent for the untouched inline table.
1271        assert!(migrate_text(&migrated).unwrap().is_none());
1272    }
1273
1274    #[test]
1275    fn migrate_document_leaves_an_inline_general_table_unmodified() {
1276        let text = "#:schema x\ngeneral = { default_time_range = \"weekly\", version = 2 }\n";
1277        assert!(migrate_text(text).unwrap().is_none());
1278        let cfg = parse_config_text(text);
1279        assert_eq!(cfg.general.default_time_range, TimeRange::Weekly);
1280        assert!(cfg.general.auto_update);
1281    }
1282
1283    #[test]
1284    fn migrate_text_errors_on_malformed_toml() {
1285        assert!(migrate_text("= not valid").is_err());
1286    }
1287
1288    #[test]
1289    fn migrate_document_drops_a_malformed_legacy_refresh_value() {
1290        // A non-u64 legacy value must NOT be promoted into the typed field (that
1291        // would make serde reject the migrated file and reset every setting); it
1292        // is dropped so the default applies, and unrelated settings survive.
1293        let text = "[general]\ndefault_time_range = \"weekly\"\n[usage]\nrefresh_interval_secs = -5\n[providers]\ncursor = false\n";
1294        let migrated = migrate_text(text).unwrap().expect("legacy file changes");
1295        assert!(!migrated.contains("refresh_interval = -5"));
1296        assert!(!migrated.contains("refresh_interval_secs"));
1297        let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
1298        assert_eq!(cfg.general.default_time_range, TimeRange::Weekly);
1299        assert!(!cfg.providers.cursor);
1300        assert_eq!(cfg.usage.refresh_interval, 10);
1301        // The now-clean file is stable across further passes.
1302        assert!(migrate_text(&migrated).unwrap().is_none());
1303    }
1304
1305    #[test]
1306    fn migrate_document_preserves_a_hand_added_comment_on_a_renamed_key() {
1307        let text = "[usage]\n# keep me\nrefresh_interval_secs = 15\n";
1308        let migrated = migrate_text(text).unwrap().expect("legacy file changes");
1309        assert!(migrated.contains("# keep me"));
1310        assert!(migrated.contains("refresh_interval = 15"));
1311    }
1312
1313    #[test]
1314    fn migrate_document_adds_the_directive_despite_a_buried_schema_line() {
1315        // A `#:schema`-prefixed line inside a string value must not be mistaken
1316        // for the real leading directive and skip the prepend.
1317        let text = "[usage]\nquota_panels = [\"claude\"]\nnote = \"\"\"\n#:schema fake\n\"\"\"\n";
1318        let migrated = migrate_text(text)
1319            .unwrap()
1320            .expect("adds the real directive");
1321        assert!(migrated.starts_with("#:schema https://"));
1322    }
1323
1324    #[test]
1325    fn migrate_document_merges_panels_into_an_existing_quota_table() {
1326        // Legacy quota_panels + a `[usage.quota]` that has refresh_interval but no
1327        // panels: panels is added and the user's refresh_interval survives.
1328        let text = "[usage]\nquota_panels = [\"claude\"]\n\n[usage.quota]\nrefresh_interval = 30\n";
1329        let migrated = migrate_text(text).unwrap().expect("legacy file changes");
1330        assert!(!migrated.contains("quota_panels"));
1331        let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
1332        assert_eq!(
1333            cfg.usage.quota.panels,
1334            vec!["claude".to_string(), "grok".to_string()]
1335        );
1336        assert_eq!(cfg.usage.quota.refresh_interval, 30);
1337        assert!(migrate_text(&migrated).unwrap().is_none());
1338    }
1339
1340    #[test]
1341    fn migrate_document_drops_legacy_quota_panels_when_new_panels_present() {
1342        let text = "#:schema x\n[usage]\nquota_panels = [\"gemini\"]\n\n[usage.quota]\npanels = [\"claude\"]\n";
1343        let migrated = migrate_text(text).unwrap().expect("drops the legacy key");
1344        assert!(!migrated.contains("quota_panels"));
1345        let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
1346        assert_eq!(
1347            cfg.usage.quota.panels,
1348            vec!["claude".to_string(), "grok".to_string()]
1349        );
1350    }
1351
1352    #[test]
1353    fn migrate_document_skips_an_inline_quota_child() {
1354        // An inline `quota = { ... }` under `[usage]` is left untouched (with its
1355        // legacy sibling) rather than risk losing data merging into it — and no
1356        // version marker is stamped, so the file is not recorded as upgraded.
1357        let text =
1358            "#:schema x\n[usage]\nquota_panels = [\"claude\"]\nquota = { panels = [\"codex\"] }\n";
1359        assert!(migrate_text(text).unwrap().is_none());
1360        assert!(panels_out_of_reach(&text.parse::<DocumentMut>().unwrap()));
1361    }
1362
1363    /// The one-shot contract: a panel that shipped after the file was written is
1364    /// added once, and removing it afterwards sticks.
1365    #[test]
1366    fn backfilled_panel_is_added_once_and_stays_removed() {
1367        let text = "#:schema x\n[general]\ndefault_time_range = \"all\"\n\n[usage.quota]\npanels = [\"claude\", \"codex\"]\n";
1368        let migrated = migrate_text(text)
1369            .unwrap()
1370            .expect("version 1 file upgrades");
1371        assert!(migrated.contains("panels = [\"claude\", \"codex\", \"grok\"]"));
1372        assert!(migrated.contains("version = 3"));
1373        assert!(migrate_text(&migrated).unwrap().is_none());
1374
1375        // The user removes it again: the marker keeps the upgrade from re-running.
1376        let pruned = migrated.replace(
1377            "[\"claude\", \"codex\", \"grok\"]",
1378            "[\"claude\", \"codex\"]",
1379        );
1380        assert!(migrate_text(&pruned).unwrap().is_none());
1381        let cfg: Config = toml_edit::de::from_str(&pruned).unwrap();
1382        assert!(!cfg.usage.shows_quota_panel("grok"));
1383    }
1384
1385    /// A panel the user dropped *before* this upgrade is not resurrected: only
1386    /// the names a later version introduced are back-filled.
1387    #[test]
1388    fn backfill_leaves_previously_removed_panels_alone() {
1389        let text = "#:schema x\n[usage.quota]\npanels = [\"claude\"]\n";
1390        let migrated = migrate_text(text)
1391            .unwrap()
1392            .expect("version 1 file upgrades");
1393        let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
1394        assert_eq!(
1395            cfg.usage.quota.panels,
1396            vec!["claude".to_string(), "grok".to_string()],
1397            "codex / copilot / cursor were removed on purpose and stay removed"
1398        );
1399    }
1400
1401    #[test]
1402    fn backfill_never_reopens_a_band_turned_off() {
1403        // `panels = []` hides the whole band; the upgrade must not undo that.
1404        let text = "#:schema x\n[usage.quota]\npanels = []\n";
1405        let migrated = migrate_text(text).unwrap().expect("the marker is stamped");
1406        let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
1407        assert!(cfg.usage.quota.panels.is_empty());
1408        assert_eq!(cfg.general.version, CONFIG_VERSION);
1409    }
1410
1411    #[test]
1412    fn backfill_stamps_a_file_with_no_general_section() {
1413        // A hand-written file may have no `[general]` at all; the marker still
1414        // needs somewhere to live, and the result must stay valid TOML.
1415        let text = "#:schema x\n[providers]\ncursor = false\n";
1416        let migrated = migrate_text(text).unwrap().expect("the marker is stamped");
1417        let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
1418        assert_eq!(cfg.general.version, CONFIG_VERSION);
1419        assert!(!cfg.providers.cursor);
1420        // An absent panel list simply picks up the current default.
1421        assert!(cfg.usage.shows_quota_panel("grok"));
1422        assert!(migrate_text(&migrated).unwrap().is_none());
1423    }
1424
1425    #[test]
1426    fn an_empty_file_reaches_the_current_layout_in_one_pass() {
1427        // The back-fill creates `[general]` on a file that had no tables at all,
1428        // so the `#:schema` directive has to be attached after it — otherwise
1429        // the first pass writes a file the second pass rewrites again.
1430        for text in ["", "# just a comment\n"] {
1431            let migrated = migrate_text(text)
1432                .unwrap()
1433                .expect("an empty file is stamped");
1434            assert!(migrated.starts_with("#:schema "));
1435            assert!(migrated.contains("version = 3"));
1436            assert!(
1437                migrate_text(&migrated).unwrap().is_none(),
1438                "one pass must be enough"
1439            );
1440        }
1441    }
1442
1443    #[test]
1444    fn a_panel_list_that_could_not_be_moved_is_not_marked_upgraded() {
1445        // `migrate_quota_panels` refuses to merge into an inline `quota` child,
1446        // so the legacy list survives out of reach. Stamping the marker here
1447        // would retire the one-shot upgrade without ever having run it.
1448        let text =
1449            "#:schema x\n[usage]\nquota_panels = [\"claude\"]\nquota = { refresh_interval = 30 }\n";
1450        assert!(migrate_text(text).unwrap().is_none());
1451        assert!(panels_out_of_reach(&text.parse::<DocumentMut>().unwrap()));
1452    }
1453
1454    #[test]
1455    fn a_hand_edited_version_value_never_resets_the_config() {
1456        // A non-integer marker must read as legacy, not fail the document: the
1457        // infallible read would turn that into a silent reset of every setting.
1458        for bad in ["\"2\"", "2.5", "true", "-1"] {
1459            let text = format!(
1460                "[general]\nversion = {bad}\ndefault_time_range = \"weekly\"\n[providers]\ncursor = false\n"
1461            );
1462            let cfg: Config = toml_edit::de::from_str(&text).expect("document still parses");
1463            assert_eq!(cfg.general.version, legacy_config_version(), "bad = {bad}");
1464            assert_eq!(cfg.general.default_time_range, TimeRange::Weekly);
1465            assert!(!cfg.providers.cursor);
1466            // Being read as legacy, the upgrade pass rewrites it with a real one.
1467            let migrated = migrate_text(&text)
1468                .unwrap()
1469                .expect("the marker is repaired");
1470            let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
1471            assert_eq!(cfg.general.version, CONFIG_VERSION);
1472        }
1473    }
1474
1475    #[test]
1476    fn every_backfilled_panel_ships_by_the_current_version() {
1477        for (version, name) in PANELS_ADDED_BY_VERSION {
1478            assert!(
1479                *version <= CONFIG_VERSION,
1480                "{name} is scheduled for a version this build never stamps"
1481            );
1482            assert!(
1483                default_quota_panels().iter().any(|p| p == name),
1484                "{name} is back-filled but is not a current default panel"
1485            );
1486        }
1487    }
1488
1489    #[test]
1490    fn refresh_secs_clamps_zero_to_one() {
1491        let cfg = UsageConfig {
1492            refresh_interval: 0,
1493            quota: QuotaConfig {
1494                refresh_interval: 0,
1495                ..QuotaConfig::default()
1496            },
1497            ..UsageConfig::default()
1498        };
1499        assert_eq!(cfg.refresh_secs(), 1);
1500        assert_eq!(cfg.quota_refresh_secs(), 1);
1501    }
1502
1503    #[test]
1504    fn providers_default_all_enabled() {
1505        let p = ProvidersConfig::default();
1506        assert!(
1507            p.claude
1508                && p.codex
1509                && p.copilot
1510                && p.gemini
1511                && p.opencode
1512                && p.cursor
1513                && p.hermes
1514                && p.grok
1515        );
1516    }
1517
1518    #[test]
1519    fn committed_schema_matches_generated() {
1520        // Guards `vct.schema.json` against drift from the typed Config.
1521        // Regenerate with: `vct config schema > vct.schema.json`.
1522        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../vct.schema.json");
1523        let committed: Value =
1524            serde_json::from_str(&std::fs::read_to_string(path).expect("vct.schema.json exists"))
1525                .expect("vct.schema.json is valid JSON");
1526        assert_eq!(committed, json_schema());
1527    }
1528}