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