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/// The full settings document.
42#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
43pub struct Config {
44    #[serde(default)]
45    pub general: GeneralConfig,
46    #[serde(default)]
47    pub usage: UsageConfig,
48    #[serde(default)]
49    pub analysis: AnalysisConfig,
50    #[serde(default)]
51    pub performance: PerformanceConfig,
52    #[serde(default)]
53    pub providers: ProvidersConfig,
54    #[serde(default)]
55    pub logging: LoggingConfig,
56}
57
58/// `[performance]` - controls for CPU-bound local scans.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
60pub struct PerformanceConfig {
61    /// Rayon workers used by CLI session scans. `0` selects the measured auto
62    /// default; a positive value is capped at the machine's available parallelism.
63    #[serde(default)]
64    pub scan_threads: usize,
65}
66
67impl PerformanceConfig {
68    /// Resolves the CLI scan worker count.
69    ///
70    /// A positive config value wins, followed by a positive
71    /// `RAYON_NUM_THREADS`; otherwise auto mode uses at most two workers. Every
72    /// result is capped at the available parallelism and is at least one.
73    pub fn resolved_scan_threads(&self) -> usize {
74        let available = std::thread::available_parallelism()
75            .map(std::num::NonZeroUsize::get)
76            .unwrap_or(1);
77        self.resolved_scan_threads_with(
78            available,
79            std::env::var("RAYON_NUM_THREADS").ok().as_deref(),
80        )
81    }
82
83    fn resolved_scan_threads_with(&self, available: usize, rayon_env: Option<&str>) -> usize {
84        let available = available.max(1);
85        let requested = if self.scan_threads > 0 {
86            self.scan_threads
87        } else {
88            rayon_env
89                .and_then(|value| value.parse::<usize>().ok())
90                .filter(|value| *value > 0)
91                .unwrap_or_else(|| available.min(2))
92        };
93        requested.clamp(1, available)
94    }
95}
96
97/// `[general]` — settings shared across subcommands.
98#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
99pub struct GeneralConfig {
100    /// Default time range when no --daily/--weekly/--monthly/--all flag is given.
101    /// One of: "daily" | "weekly" | "monthly" | "all".
102    #[serde(default)]
103    pub default_time_range: TimeRange,
104}
105
106/// `[usage]` — usage dashboard preferences.
107#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
108pub struct UsageConfig {
109    /// Start the usage dashboard with models merged across provider prefixes.
110    /// Toggled live with `m`; the last state is saved back here.
111    #[serde(default)]
112    pub merge_models: bool,
113    /// Seconds between automatic redraws of the usage TUI (minimum 1).
114    #[serde(default = "default_refresh_secs")]
115    pub refresh_interval: u64,
116    /// Live quota-panel preferences.
117    #[serde(default)]
118    pub quota: QuotaConfig,
119}
120
121impl Default for UsageConfig {
122    fn default() -> Self {
123        Self {
124            merge_models: false,
125            refresh_interval: default_refresh_secs(),
126            quota: QuotaConfig::default(),
127        }
128    }
129}
130
131impl UsageConfig {
132    /// The usage TUI redraw cadence, clamped to a sane minimum so a `0` cannot
133    /// busy-loop.
134    pub fn refresh_secs(&self) -> u64 {
135        self.refresh_interval.max(1)
136    }
137
138    /// The live quota-panel poll cadence, clamped to a sane minimum.
139    pub fn quota_refresh_secs(&self) -> u64 {
140        self.quota.refresh_interval.max(1)
141    }
142
143    /// Whether the quota panel for `provider` is enabled (case-insensitive).
144    pub fn shows_quota_panel(&self, provider: &str) -> bool {
145        quota_panel_selected(&self.quota.panels, provider)
146    }
147}
148
149/// `[usage.quota]` — live quota-panel preferences.
150#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
151pub struct QuotaConfig {
152    /// Which live quota panels to show in the usage TUI (by provider name:
153    /// `claude` / `codex` / `copilot` / `cursor`). An empty list hides the band.
154    #[serde(default = "default_quota_panels")]
155    pub panels: Vec<String>,
156    /// Seconds between live quota-panel polls, shared by every provider
157    /// (minimum 1). Higher is safer against a provider's rate limits.
158    #[serde(default = "default_quota_refresh_secs")]
159    pub refresh_interval: u64,
160}
161
162impl Default for QuotaConfig {
163    fn default() -> Self {
164        Self {
165            panels: default_quota_panels(),
166            refresh_interval: default_quota_refresh_secs(),
167        }
168    }
169}
170
171/// Whether `name` is one of the selected quota-panel names (case-insensitive).
172///
173/// The single home for the panel-selection rule, shared by
174/// [`UsageConfig::shows_quota_panel`] and the usage TUI's panel masking.
175pub fn quota_panel_selected(panels: &[String], name: &str) -> bool {
176    panels.iter().any(|p| p.eq_ignore_ascii_case(name))
177}
178
179/// `[analysis]` — analysis dashboard preferences.
180#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
181pub struct AnalysisConfig {
182    /// Seconds between automatic redraws of the analysis TUI (minimum 1).
183    #[serde(default = "default_refresh_secs")]
184    pub refresh_interval: u64,
185}
186
187impl Default for AnalysisConfig {
188    fn default() -> Self {
189        Self {
190            refresh_interval: default_refresh_secs(),
191        }
192    }
193}
194
195impl AnalysisConfig {
196    /// The refresh cadence, clamped to a sane minimum so a `0` cannot busy-loop.
197    pub fn refresh_secs(&self) -> u64 {
198        self.refresh_interval.max(1)
199    }
200}
201
202/// `[providers]` — per-provider include toggles.
203///
204/// Each provider defaults to `true`; setting one to `false` skips it entirely
205/// (no directory scan, no API) in both the usage and analysis roll-ups.
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
207pub struct ProvidersConfig {
208    #[serde(default = "default_true")]
209    pub claude: bool,
210    #[serde(default = "default_true")]
211    pub codex: bool,
212    #[serde(default = "default_true")]
213    pub copilot: bool,
214    #[serde(default = "default_true")]
215    pub gemini: bool,
216    #[serde(default = "default_true")]
217    pub opencode: bool,
218    #[serde(default = "default_true")]
219    pub cursor: bool,
220    #[serde(default = "default_true")]
221    pub hermes: bool,
222    #[serde(default = "default_true")]
223    pub grok: bool,
224}
225
226impl Default for ProvidersConfig {
227    fn default() -> Self {
228        Self {
229            claude: true,
230            codex: true,
231            copilot: true,
232            gemini: true,
233            opencode: true,
234            cursor: true,
235            hermes: true,
236            grok: true,
237        }
238    }
239}
240
241/// `[logging]` — file logging preferences.
242///
243/// Diagnostics are written to `~/.vct/logs/vct-YYYY-MM-DD.log` (plain text, one
244/// line per record). Nothing is ever printed to the terminal, so the TUI is
245/// never disturbed. The log file is created lazily on the first record, so a
246/// command that logs nothing never touches `~/.vct`.
247#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
248pub struct LoggingConfig {
249    /// Minimum level written to the log file.
250    /// One of: "off" | "error" | "warn" | "info" | "debug" | "trace".
251    #[serde(default)]
252    pub level: LogLevel,
253    /// Days of daily log files to keep; older `vct-*.log` files are pruned on
254    /// startup. `0` keeps every file.
255    #[serde(default = "default_log_retention_days")]
256    pub retention_days: u32,
257}
258
259impl Default for LoggingConfig {
260    fn default() -> Self {
261        Self {
262            level: LogLevel::default(),
263            retention_days: default_log_retention_days(),
264        }
265    }
266}
267
268/// Minimum severity written to the log file.
269#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
270#[serde(rename_all = "lowercase")]
271pub enum LogLevel {
272    /// Disable file logging entirely.
273    Off,
274    /// Only errors.
275    Error,
276    /// Errors and warnings (the default — quiet when healthy, records problems).
277    #[default]
278    Warn,
279    /// Adds high-level informational breadcrumbs.
280    Info,
281    /// Adds verbose diagnostics (per-file parse skips, quota poll detail).
282    Debug,
283    /// Everything, including cache-hit traces.
284    Trace,
285}
286
287fn default_true() -> bool {
288    true
289}
290
291fn default_log_retention_days() -> u32 {
292    7
293}
294
295fn default_refresh_secs() -> u64 {
296    10
297}
298
299fn default_quota_refresh_secs() -> u64 {
300    60
301}
302
303fn default_quota_panels() -> Vec<String> {
304    ["claude", "codex", "copilot", "cursor"]
305        .iter()
306        .map(|s| s.to_string())
307        .collect()
308}
309
310/// Loads settings from `~/.vct/config.toml`, creating it with defaults on first
311/// run.
312///
313/// Infallible: any error resolving, reading, or parsing degrades to
314/// [`Config::default`].
315pub fn load() -> Config {
316    match get_cache_dir() {
317        Ok(dir) => load_in(&dir),
318        Err(_) => Config::default(),
319    }
320}
321
322/// [`load`] rooted at an explicit directory (test seam).
323pub fn load_in(dir: &Path) -> Config {
324    let path = dir.join("config.toml");
325    if path.exists() {
326        let Ok(text) = std::fs::read_to_string(&path) else {
327            return Config::default();
328        };
329        // Auto-migrate a legacy-format file in place so existing users pick up the
330        // renamed keys, the `[usage.quota]` nesting, and the `#:schema` directive.
331        // Best-effort: a failed write (e.g. a read-only home) still yields a
332        // correct in-memory Config below. Malformed TOML is never overwritten.
333        let effective = match migrate_text(&text) {
334            Ok(Some(migrated)) => {
335                let _ = write_string_atomic(&path, &migrated);
336                migrated
337            }
338            Ok(None) => text,
339            Err(_) => return Config::default(),
340        };
341        let mut config: Config = toml_edit::de::from_str(&effective).unwrap_or_default();
342        // Backstop for any residual legacy form the structural migration leaves
343        // alone (e.g. an inline `usage = { ... }` table).
344        migrate_legacy(&mut config, &effective);
345        return config;
346    }
347    // First run: materialize the generated commented template.
348    let text = default_document().to_string();
349    let _ = write_string_atomic(&path, &text);
350    toml_edit::de::from_str(&text).unwrap_or_default()
351}
352
353/// Outcome of an explicit [`migrate_config_file`] run, surfaced by
354/// `vct config migrate`.
355#[derive(Debug, Clone, Copy, PartialEq, Eq)]
356pub enum MigrationStatus {
357    /// No file existed, so a fresh commented default was written.
358    Created,
359    /// A legacy-format file was rewritten to the current on-disk format.
360    Migrated,
361    /// The file was already current; nothing was written.
362    AlreadyCurrent,
363}
364
365/// Migrates the `config.toml` at `path` to the current on-disk format in place,
366/// creating the commented default when the file is absent. Shared by
367/// `vct config migrate` and the auto-migration in [`load_in`].
368pub fn migrate_config_file(path: &Path) -> Result<MigrationStatus> {
369    if !path.exists() {
370        write_string_atomic(path, &default_document().to_string())?;
371        return Ok(MigrationStatus::Created);
372    }
373    let text = std::fs::read_to_string(path)?;
374    match migrate_text(&text)? {
375        Some(migrated) => {
376            write_string_atomic(path, &migrated)?;
377            Ok(MigrationStatus::Migrated)
378        }
379        None => Ok(MigrationStatus::AlreadyCurrent),
380    }
381}
382
383/// Applies the structural migration to raw config text: `Ok(Some(new))` when it
384/// changed, `Ok(None)` when already current, `Err` when the text is not valid
385/// TOML (so a caller never overwrites an unparseable file with defaults).
386pub fn migrate_text(text: &str) -> Result<Option<String>> {
387    let mut doc: DocumentMut = text.parse()?;
388    Ok(migrate_document(&mut doc).then(|| doc.to_string()))
389}
390
391/// Read-time backstop for config files written before quota settings moved into
392/// `[usage.quota]`: an old `[usage].quota_panels` key is honored when the new
393/// `[usage.quota].panels` key is absent, and a legacy `refresh_interval_secs` is
394/// mapped onto `refresh_interval`. The on-disk file is normally rewritten by
395/// [`migrate_document`] first; this shim only has to cover the forms that pass
396/// leaves alone (e.g. an inline `usage = { ... }` table), keeping the in-memory
397/// [`Config`] correct even when the file itself was not upgraded.
398fn migrate_legacy(config: &mut Config, raw: &str) {
399    let Ok(doc) = raw.parse::<DocumentMut>() else {
400        return;
401    };
402    if let Some(usage) = doc.get("usage").and_then(Item::as_table_like) {
403        // [usage].quota_panels -> [usage.quota].panels. A present new key (even
404        // `panels = []`) always wins over the legacy one.
405        let has_new_panels = usage
406            .get("quota")
407            .and_then(Item::as_table_like)
408            .is_some_and(|q| q.contains_key("panels"));
409        if !has_new_panels && let Some(legacy) = usage.get("quota_panels").and_then(Item::as_array)
410        {
411            config.usage.quota.panels = legacy
412                .iter()
413                .filter_map(|v| v.as_str().map(str::to_string))
414                .collect();
415        }
416        migrate_refresh_secs(usage, &mut config.usage.refresh_interval);
417    }
418    if let Some(analysis) = doc.get("analysis").and_then(Item::as_table_like) {
419        migrate_refresh_secs(analysis, &mut config.analysis.refresh_interval);
420    }
421}
422
423/// Honors a legacy `refresh_interval_secs` when the new `refresh_interval` key
424/// is absent from the section. This replaces a serde `alias`, which would make
425/// serde reject a mid-upgrade file carrying *both* names as a duplicate field
426/// and (via the infallible read) silently reset the whole config to defaults.
427fn migrate_refresh_secs(section: &dyn TableLike, target: &mut u64) {
428    if !section.contains_key("refresh_interval")
429        && let Some(secs) = section
430            .get("refresh_interval_secs")
431            .and_then(Item::as_integer)
432        && let Ok(v) = u64::try_from(secs)
433    {
434        *target = v;
435    }
436}
437
438/// Structural on-disk migration for a `config.toml` written by an older vct.
439///
440/// Operates on standard `[header]` tables only — an inline `usage = { ... }`
441/// table is left to the read-time [`migrate_legacy`] shim, so this never has to
442/// synthesize a header table inside an inline one. Returns whether anything
443/// changed, so the caller rewrites the file at most once. Idempotent:
444/// re-running on an already-migrated document returns `false`.
445fn migrate_document(doc: &mut DocumentMut) -> bool {
446    let schema = json_schema();
447    let mut changed = false;
448
449    // The `#:schema` directive on the first line drives editor autocomplete.
450    if !has_schema_directive(doc) {
451        changed |= prepend_schema_directive(doc);
452    }
453
454    // `[usage]`: rename the refresh key, then move quota_panels into the nested
455    // `[usage.quota]`. Quota goes last so its `[usage.quota]` header renders
456    // after every leaf key of `[usage]` (a leaf after a sub-table would be
457    // invalid TOML).
458    if doc.get("usage").is_some_and(Item::is_table) {
459        if let Some(usage) = doc.get_mut("usage").and_then(Item::as_table_mut) {
460            changed |= rename_refresh_key(usage, &schema, &["usage", "refresh_interval"]);
461        }
462        changed |= migrate_quota_panels(doc, &schema);
463    }
464
465    // `[analysis]`: rename the refresh key.
466    if let Some(analysis) = doc.get_mut("analysis").and_then(Item::as_table_mut) {
467        changed |= rename_refresh_key(analysis, &schema, &["analysis", "refresh_interval"]);
468    }
469
470    changed
471}
472
473/// Whether the document already carries a `#:schema` directive line.
474fn has_schema_directive(doc: &DocumentMut) -> bool {
475    // Only the first non-blank line can be a real directive; scanning every line
476    // would false-positive on a `#:schema`-prefixed line buried inside a
477    // multi-line string value or a later comment, wrongly skipping the prepend.
478    doc.to_string()
479        .lines()
480        .find(|line| !line.trim().is_empty())
481        .is_some_and(|line| line.trim_start().starts_with("#:schema"))
482}
483
484/// Prepends the `#:schema` directive to the document's leading trivia (the
485/// prefix decor of its first element). Returns whether it was added.
486fn prepend_schema_directive(doc: &mut DocumentMut) -> bool {
487    let directive = format!("#:schema {SCHEMA_URL}\n");
488    let root = doc.as_table_mut();
489    let Some(first_key) = root.iter().next().map(|(k, _)| k.to_string()) else {
490        return false;
491    };
492    if let Some(item) = root.get_mut(&first_key)
493        && let Some(table) = item.as_table_mut()
494    {
495        let existing = decor_prefix(table.decor());
496        table
497            .decor_mut()
498            .set_prefix(format!("{directive}{existing}"));
499        return true;
500    }
501    if let Some(mut km) = root.key_mut(&first_key) {
502        let existing = decor_prefix(km.leaf_decor());
503        km.leaf_decor_mut()
504            .set_prefix(format!("{directive}{existing}"));
505        return true;
506    }
507    false
508}
509
510/// The existing prefix text of a decor, or an empty string.
511fn decor_prefix(decor: &toml_edit::Decor) -> String {
512    decor
513        .prefix()
514        .and_then(|p| p.as_str())
515        .unwrap_or("")
516        .to_string()
517}
518
519/// Within `table`, migrate a legacy `refresh_interval_secs` to the current
520/// `refresh_interval`: rename when only the old key is present, or drop the old
521/// key when both coexist (a mid-upgrade file where the new key already wins).
522///
523/// A malformed legacy value (negative, float, string) is **dropped**, not
524/// promoted: writing it into the typed `refresh_interval` field would make serde
525/// reject the whole migrated file and silently reset every setting to defaults.
526/// Dropping it lets the typed default apply, matching the pre-migration read.
527fn rename_refresh_key(table: &mut Table, schema: &Value, comment_path: &[&str]) -> bool {
528    if !table.contains_key("refresh_interval_secs") {
529        return false;
530    }
531    // Both present: the new key already wins on read, so just drop the stale one.
532    if table.contains_key("refresh_interval") {
533        table.remove("refresh_interval_secs");
534        return true;
535    }
536    let Some((old_key, item)) = table.remove_entry("refresh_interval_secs") else {
537        return false;
538    };
539    if let Some(secs) = item.as_integer().and_then(|n| u64::try_from(n).ok()) {
540        table.insert("refresh_interval", value(secs as i64));
541        // Keep a hand-added comment; otherwise apply the current schema comment.
542        let existing = decor_prefix(old_key.leaf_decor());
543        apply_comment(table, "refresh_interval", &existing, schema, comment_path);
544    }
545    true
546}
547
548/// Moves a legacy top-level `[usage].quota_panels` array into the nested
549/// `[usage.quota].panels`, creating the `[usage.quota]` table (with the current
550/// default `refresh_interval`) when needed. A present `[usage.quota].panels`
551/// wins; the legacy key is removed once handled. An inline `quota = { ... }`
552/// child is left untouched (legacy key preserved) so no data is lost trying to
553/// merge into an inline table.
554fn migrate_quota_panels(doc: &mut DocumentMut, schema: &Value) -> bool {
555    let Some(usage) = doc.get("usage").and_then(Item::as_table) else {
556        return false;
557    };
558    if !usage.contains_key("quota_panels") {
559        return false;
560    }
561    if usage.get("quota").is_some_and(Item::is_inline_table) {
562        return false;
563    }
564    let has_new_panels = usage
565        .get("quota")
566        .and_then(Item::as_table_like)
567        .is_some_and(|q| q.contains_key("panels"));
568
569    let usage = doc
570        .get_mut("usage")
571        .and_then(Item::as_table_mut)
572        .expect("usage table present");
573    let Some((old_key, old_item)) = usage.remove_entry("quota_panels") else {
574        return false;
575    };
576    if has_new_panels {
577        // The new `[usage.quota].panels` already wins; drop the legacy key only.
578        return true;
579    }
580    let legacy_panels: Vec<String> = old_item
581        .as_array()
582        .map(|a| {
583            a.iter()
584                .filter_map(|v| v.as_str().map(str::to_string))
585                .collect()
586        })
587        .unwrap_or_default();
588    // Carry the legacy key's comment onto the new `panels` key.
589    let comment = decor_prefix(old_key.leaf_decor());
590
591    if usage.get("quota").and_then(Item::as_table).is_some() {
592        // Existing `[usage.quota]` without a panels key: just add it.
593        let quota = usage
594            .get_mut("quota")
595            .and_then(Item::as_table_mut)
596            .expect("quota table present");
597        quota.insert("panels", value(panels_array(&legacy_panels)));
598        apply_comment(
599            quota,
600            "panels",
601            &comment,
602            schema,
603            &["usage", "quota", "panels"],
604        );
605    } else {
606        usage.insert(
607            "quota",
608            Item::Table(build_quota_table(&legacy_panels, &comment, schema)),
609        );
610    }
611    true
612}
613
614/// A TOML array from a list of panel names.
615fn panels_array(panels: &[String]) -> Array {
616    panels.iter().map(String::as_str).collect()
617}
618
619/// Builds a fresh `[usage.quota]` table from the migrated panels (carrying the
620/// legacy key's comment when it had one) and the current default
621/// `refresh_interval`, which is brand-new so it gets the schema comment.
622fn build_quota_table(panels: &[String], panels_comment: &str, schema: &Value) -> Table {
623    let mut table = Table::new();
624    table.set_implicit(false);
625    if let Some(desc) = schema_description(schema, &["usage", "quota"]) {
626        table
627            .decor_mut()
628            .set_prefix(format!("\n{}", comment_block(&desc)));
629    }
630    table.insert("panels", value(panels_array(panels)));
631    apply_comment(
632        &mut table,
633        "panels",
634        panels_comment,
635        schema,
636        &["usage", "quota", "panels"],
637    );
638    table.insert(
639        "refresh_interval",
640        value(default_quota_refresh_secs() as i64),
641    );
642    apply_comment(
643        &mut table,
644        "refresh_interval",
645        "",
646        schema,
647        &["usage", "quota", "refresh_interval"],
648    );
649    table
650}
651
652/// Attaches a leading `#` comment to `key`, mirroring the leaf styling in
653/// [`annotate_table`]: keeps a non-empty `existing` comment (a hand-added or
654/// carried-over one), otherwise falls back to the field's schema `description`.
655fn apply_comment(table: &mut Table, key: &str, existing: &str, schema: &Value, path: &[&str]) {
656    let prefix = if existing.trim().is_empty() {
657        schema_description(schema, path).map(|desc| format!("\n{}", comment_block(&desc)))
658    } else {
659        Some(existing.to_string())
660    };
661    if let Some(prefix) = prefix
662        && let Some(mut km) = table.key_mut(key)
663    {
664        km.leaf_decor_mut().set_prefix(prefix);
665    }
666}
667
668/// Walks the (inlined) JSON schema to a nested field's `description`.
669fn schema_description(schema: &Value, path: &[&str]) -> Option<String> {
670    let mut cur = schema;
671    for key in path {
672        cur = cur.get("properties")?.get(key)?;
673    }
674    cur.get("description")
675        .and_then(Value::as_str)
676        .map(str::to_string)
677}
678
679/// Persists the usage dashboard's merge toggle back to the config.
680pub fn save_merge_models(enabled: bool) -> Result<()> {
681    save_merge_models_in(&get_cache_dir()?, enabled)
682}
683
684/// [`save_merge_models`] rooted at an explicit directory (test seam).
685pub fn save_merge_models_in(dir: &Path, enabled: bool) -> Result<()> {
686    edit_in(dir, |doc| {
687        // A hand-edited file could make `usage` a scalar (`usage = "bad"`);
688        // replace any non-table-like value with an empty table so indexing into
689        // it below cannot panic. `is_table_like` accepts both the `[usage]`
690        // header form AND an inline table (`usage = { ... }`), so a valid config
691        // in either form is left intact (its keys/comments preserved) — only a
692        // genuine scalar is repaired.
693        if !doc["usage"].is_table_like() {
694            doc["usage"] = Item::Table(Table::new());
695        }
696        doc["usage"]["merge_models"] = value(enabled);
697    })
698}
699
700/// Reads the current document (or the template when absent/malformed), applies
701/// `mutate`, and writes it back atomically — preserving formatting and comments.
702fn edit_in(dir: &Path, mutate: impl FnOnce(&mut DocumentMut)) -> Result<()> {
703    let path = dir.join("config.toml");
704    let mut doc = std::fs::read_to_string(&path)
705        .ok()
706        .and_then(|text| text.parse::<DocumentMut>().ok())
707        .unwrap_or_else(default_document);
708    mutate(&mut doc);
709    write_string_atomic(&path, &doc.to_string())
710}
711
712/// The JSON schema for the settings file, used both by `vct config schema` and
713/// to derive the commented default template. Subschemas are inlined so the
714/// document is self-contained and each field's `description` is easy to walk.
715pub fn json_schema() -> Value {
716    let settings =
717        schemars::generate::SchemaSettings::draft2020_12().with(|s| s.inline_subschemas = true);
718    let schema = settings.into_generator().into_root_schema_for::<Config>();
719    serde_json::to_value(&schema).expect("config schema serializes to JSON")
720}
721
722/// The pretty-printed JSON schema (the committed `vct.schema.json` body, also
723/// what `vct config schema` prints). Ends with a trailing newline.
724pub fn schema_json() -> String {
725    let mut s = serde_json::to_string_pretty(&json_schema()).expect("schema serializes");
726    s.push('\n');
727    s
728}
729
730/// Builds the commented default `config.toml` document from the typed
731/// [`Config`]: values come from [`Config::default`] (serialized via `toml_edit`),
732/// per-key and per-section comments come from the schema `description`s, and the
733/// `#:schema` directive is placed on the first line.
734fn default_document() -> DocumentMut {
735    let mut doc: DocumentMut = toml_edit::ser::to_string(&Config::default())
736        .expect("default config serializes to TOML")
737        .parse()
738        .expect("serialized default config is valid TOML");
739    let schema = json_schema();
740    annotate_table(doc.as_table_mut(), &schema, true);
741    doc
742}
743
744/// Recursively attaches each field's schema `description` as a leading `#`
745/// comment to the matching key or sub-table of `table`. On the root table the
746/// `#:schema` directive is prepended to the first section.
747fn annotate_table(table: &mut Table, schema: &Value, is_root: bool) {
748    let props = schema
749        .get("properties")
750        .and_then(Value::as_object)
751        .cloned()
752        .unwrap_or_default();
753    let keys: Vec<String> = table.iter().map(|(k, _)| k.to_string()).collect();
754    for (idx, key) in keys.iter().enumerate() {
755        let field_schema = props.get(key);
756        let desc = field_schema
757            .and_then(|s| s.get("description"))
758            .and_then(Value::as_str);
759        let is_table = table
760            .get(key)
761            .is_some_and(|i| i.is_table() || i.is_inline_table());
762        if is_table {
763            let field_schema = field_schema.cloned();
764            // The serializer suffixes every key with a space (right for a leaf's
765            // `k = v`, but it renders a table header as `[usage ]`); trim it.
766            if let Some(mut km) = table.key_mut(key) {
767                km.leaf_decor_mut().set_suffix("");
768            }
769            if let Some(item) = table.get_mut(key)
770                && let Some(sub) = as_standard_table(item)
771            {
772                let mut prefix = String::new();
773                if is_root && idx == 0 {
774                    prefix.push_str(&format!("#:schema {SCHEMA_URL}\n\n"));
775                } else {
776                    prefix.push('\n');
777                }
778                if let Some(d) = desc {
779                    prefix.push_str(&comment_block(d));
780                }
781                sub.decor_mut().set_prefix(prefix);
782                if let Some(fs) = field_schema {
783                    annotate_table(sub, &fs, false);
784                }
785            }
786        } else if let Some(d) = desc
787            && let Some(mut km) = table.key_mut(key)
788        {
789            km.leaf_decor_mut()
790                .set_prefix(format!("\n{}", comment_block(d)));
791        }
792    }
793}
794
795/// Coerces `item` to a standard `[header]` table, converting an inline table
796/// (`{ ... }`) in place so a leading comment can be attached to its header.
797fn as_standard_table(item: &mut Item) -> Option<&mut Table> {
798    if item.is_inline_table()
799        && let Some(inline) = item.as_inline_table().cloned()
800    {
801        *item = Item::Table(inline.into_table());
802    }
803    item.as_table_mut()
804}
805
806/// Renders a schema description as one or more `# ` comment lines (each ending
807/// in a newline), so a multi-line description becomes multi-line comments.
808fn comment_block(desc: &str) -> String {
809    desc.lines()
810        .map(|line| {
811            if line.is_empty() {
812                "#\n".to_string()
813            } else {
814                format!("# {line}\n")
815            }
816        })
817        .collect()
818}
819
820#[cfg(test)]
821mod tests {
822    use super::*;
823
824    #[test]
825    fn generated_template_parses_to_expected_defaults() {
826        let text = default_document().to_string();
827        let cfg: Config = toml_edit::de::from_str(&text).unwrap();
828        assert_eq!(cfg, Config::default());
829        assert!(text.starts_with(&format!("#:schema {SCHEMA_URL}")));
830        assert_eq!(cfg.general.default_time_range, TimeRange::All);
831        assert_eq!(cfg.usage.quota.panels, default_quota_panels());
832        assert!(cfg.usage.shows_quota_panel("cursor"));
833        assert!(!cfg.usage.merge_models);
834        assert_eq!(cfg.usage.refresh_interval, 10);
835        assert_eq!(cfg.usage.quota.refresh_interval, 60);
836        assert_eq!(cfg.analysis.refresh_interval, 10);
837        assert_eq!(cfg.performance.scan_threads, 0);
838        assert_eq!(cfg.providers, ProvidersConfig::default());
839        assert!(cfg.providers.cursor);
840        assert!(cfg.providers.grok);
841        assert_eq!(cfg.logging.level, LogLevel::Warn);
842        assert_eq!(cfg.logging.retention_days, 7);
843    }
844
845    #[test]
846    fn missing_sections_use_defaults() {
847        // An empty file must still default the opt-out settings, which only holds
848        // because the section structs impl Default by hand.
849        let cfg: Config = toml_edit::de::from_str("").unwrap();
850        assert_eq!(cfg.usage.quota.panels, default_quota_panels());
851        assert!(cfg.providers.cursor);
852        assert!(cfg.providers.grok);
853        assert_eq!(cfg.usage.refresh_secs(), 10);
854        assert_eq!(cfg.usage.quota_refresh_secs(), 60);
855        // A file with no [logging] section backfills the whole section default,
856        // which is what keeps the additive section migration-free.
857        assert_eq!(cfg.logging, LoggingConfig::default());
858        assert_eq!(cfg.performance, PerformanceConfig::default());
859    }
860
861    #[test]
862    fn scan_threads_resolve_config_then_env_then_auto() {
863        let auto = PerformanceConfig { scan_threads: 0 };
864        assert_eq!(auto.resolved_scan_threads_with(16, None), 2);
865        assert_eq!(auto.resolved_scan_threads_with(1, None), 1);
866        assert_eq!(auto.resolved_scan_threads_with(16, Some("7")), 7);
867        assert_eq!(auto.resolved_scan_threads_with(4, Some("99")), 4);
868        assert_eq!(auto.resolved_scan_threads_with(16, Some("0")), 2);
869        assert_eq!(auto.resolved_scan_threads_with(16, Some("invalid")), 2);
870
871        let configured = PerformanceConfig { scan_threads: 12 };
872        assert_eq!(configured.resolved_scan_threads_with(8, Some("3")), 8);
873    }
874
875    #[test]
876    fn partial_usage_section_keeps_quota_default() {
877        let cfg: Config = toml_edit::de::from_str("[usage]\nmerge_models = true\n").unwrap();
878        assert!(cfg.usage.merge_models);
879        assert_eq!(cfg.usage.quota.panels, default_quota_panels());
880        assert_eq!(cfg.usage.quota_refresh_secs(), 60);
881    }
882
883    #[test]
884    fn quota_panels_can_be_narrowed_or_emptied() {
885        let cfg: Config =
886            toml_edit::de::from_str("[usage.quota]\npanels = [\"claude\"]\n").unwrap();
887        assert!(cfg.usage.shows_quota_panel("claude"));
888        assert!(!cfg.usage.shows_quota_panel("cursor"));
889
890        let empty: Config = toml_edit::de::from_str("[usage.quota]\npanels = []\n").unwrap();
891        assert!(!empty.usage.shows_quota_panel("claude"));
892    }
893
894    #[test]
895    fn refresh_interval_reads_legacy_secs() {
896        // Old files used `refresh_interval_secs`; the migration shim maps it.
897        let text = "[usage]\nrefresh_interval_secs = 5\n[analysis]\nrefresh_interval_secs = 7\n";
898        let mut cfg: Config = toml_edit::de::from_str(text).unwrap();
899        migrate_legacy(&mut cfg, text);
900        assert_eq!(cfg.usage.refresh_secs(), 5);
901        assert_eq!(cfg.analysis.refresh_secs(), 7);
902    }
903
904    #[test]
905    fn coexisting_refresh_keys_preserve_the_rest_of_the_config() {
906        // A mid-upgrade file carrying BOTH the new and the legacy key must not
907        // trip serde's duplicate-field error and reset the whole config to
908        // defaults; parsing succeeds, the new key wins, and unrelated sections
909        // survive.
910        let text = "[general]\ndefault_time_range = \"weekly\"\n[usage]\nrefresh_interval = 5\nrefresh_interval_secs = 10\n[providers]\ncursor = false\n";
911        let mut cfg: Config = toml_edit::de::from_str(text).unwrap();
912        migrate_legacy(&mut cfg, text);
913        assert_eq!(cfg.usage.refresh_secs(), 5);
914        assert_eq!(cfg.general.default_time_range, TimeRange::Weekly);
915        assert!(!cfg.providers.cursor);
916    }
917
918    #[test]
919    fn migrates_legacy_quota_panels() {
920        // Pre-`[usage.quota]` layout: quota_panels sat directly under [usage].
921        let text = "[usage]\nquota_panels = [\"claude\"]\n";
922        let mut cfg: Config = toml_edit::de::from_str(text).unwrap();
923        migrate_legacy(&mut cfg, text);
924        assert_eq!(cfg.usage.quota.panels, vec!["claude".to_string()]);
925        assert!(!cfg.usage.shows_quota_panel("cursor"));
926    }
927
928    #[test]
929    fn new_quota_panels_win_over_legacy() {
930        let text = "[usage]\nquota_panels = [\"claude\"]\n[usage.quota]\npanels = []\n";
931        let mut cfg: Config = toml_edit::de::from_str(text).unwrap();
932        migrate_legacy(&mut cfg, text);
933        assert!(cfg.usage.quota.panels.is_empty());
934    }
935
936    #[test]
937    fn migrate_document_upgrades_a_full_legacy_file() {
938        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";
939        let migrated = migrate_text(legacy).unwrap().expect("legacy file changes");
940        // The `#:schema` directive lands on the first line.
941        assert!(migrated.starts_with("#:schema "));
942        // Legacy keys are gone; the current nested layout is in place.
943        assert!(!migrated.contains("quota_panels"));
944        assert!(!migrated.contains("refresh_interval_secs"));
945        assert!(migrated.contains("[usage.quota]"));
946        assert!(migrated.contains("panels = [\"claude\", \"codex\"]"));
947        // The user's values survive, and the quota default is filled in.
948        let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
949        assert_eq!(cfg.usage.refresh_interval, 15);
950        assert_eq!(cfg.analysis.refresh_interval, 20);
951        assert_eq!(
952            cfg.usage.quota.panels,
953            vec!["claude".to_string(), "codex".to_string()]
954        );
955        assert_eq!(cfg.usage.quota.refresh_interval, 60);
956    }
957
958    #[test]
959    fn migrate_document_is_idempotent() {
960        let legacy = "[usage]\nquota_panels = [\"claude\"]\nrefresh_interval_secs = 15\n";
961        let once = migrate_text(legacy).unwrap().expect("first pass changes");
962        // A second pass over the migrated text writes nothing.
963        assert!(migrate_text(&once).unwrap().is_none());
964    }
965
966    #[test]
967    fn migrate_document_noops_on_the_generated_default() {
968        let current = default_document().to_string();
969        assert!(migrate_text(&current).unwrap().is_none());
970    }
971
972    #[test]
973    fn migrate_document_adds_only_the_schema_directive_when_keys_are_current() {
974        // New key names + nested quota, but missing the `#:schema` directive.
975        let text = "[usage]\nrefresh_interval = 10\n\n[usage.quota]\npanels = [\"claude\"]\n";
976        let migrated = migrate_text(text).unwrap().expect("adds #:schema");
977        assert!(migrated.starts_with("#:schema "));
978        assert!(migrated.contains("refresh_interval = 10"));
979        assert!(migrated.contains("panels = [\"claude\"]"));
980    }
981
982    #[test]
983    fn migrate_document_drops_stale_refresh_key_when_both_present() {
984        let text = "#:schema x\n[usage]\nrefresh_interval = 5\nrefresh_interval_secs = 10\n";
985        let migrated = migrate_text(text).unwrap().expect("drops the legacy key");
986        assert!(!migrated.contains("refresh_interval_secs"));
987        let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
988        assert_eq!(cfg.usage.refresh_interval, 5);
989    }
990
991    #[test]
992    fn migrate_document_leaves_an_inline_usage_table_to_the_read_shim() {
993        // An inline `usage = { ... }` table is not restructured (only `#:schema`
994        // is added), so nothing is lost trying to nest into an inline table.
995        let text = "usage = { quota_panels = [\"claude\"], refresh_interval_secs = 30 }\n";
996        let migrated = migrate_text(text).unwrap().expect("adds #:schema");
997        assert!(migrated.starts_with("#:schema "));
998        assert!(migrated.contains("quota_panels"));
999        // Still idempotent for the untouched inline table.
1000        assert!(migrate_text(&migrated).unwrap().is_none());
1001    }
1002
1003    #[test]
1004    fn migrate_text_errors_on_malformed_toml() {
1005        assert!(migrate_text("= not valid").is_err());
1006    }
1007
1008    #[test]
1009    fn migrate_document_drops_a_malformed_legacy_refresh_value() {
1010        // A non-u64 legacy value must NOT be promoted into the typed field (that
1011        // would make serde reject the migrated file and reset every setting); it
1012        // is dropped so the default applies, and unrelated settings survive.
1013        let text = "[general]\ndefault_time_range = \"weekly\"\n[usage]\nrefresh_interval_secs = -5\n[providers]\ncursor = false\n";
1014        let migrated = migrate_text(text).unwrap().expect("legacy file changes");
1015        assert!(!migrated.contains("refresh_interval = -5"));
1016        assert!(!migrated.contains("refresh_interval_secs"));
1017        let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
1018        assert_eq!(cfg.general.default_time_range, TimeRange::Weekly);
1019        assert!(!cfg.providers.cursor);
1020        assert_eq!(cfg.usage.refresh_interval, 10);
1021        // The now-clean file is stable across further passes.
1022        assert!(migrate_text(&migrated).unwrap().is_none());
1023    }
1024
1025    #[test]
1026    fn migrate_document_preserves_a_hand_added_comment_on_a_renamed_key() {
1027        let text = "[usage]\n# keep me\nrefresh_interval_secs = 15\n";
1028        let migrated = migrate_text(text).unwrap().expect("legacy file changes");
1029        assert!(migrated.contains("# keep me"));
1030        assert!(migrated.contains("refresh_interval = 15"));
1031    }
1032
1033    #[test]
1034    fn migrate_document_adds_the_directive_despite_a_buried_schema_line() {
1035        // A `#:schema`-prefixed line inside a string value must not be mistaken
1036        // for the real leading directive and skip the prepend.
1037        let text = "[usage]\nquota_panels = [\"claude\"]\nnote = \"\"\"\n#:schema fake\n\"\"\"\n";
1038        let migrated = migrate_text(text)
1039            .unwrap()
1040            .expect("adds the real directive");
1041        assert!(migrated.starts_with("#:schema https://"));
1042    }
1043
1044    #[test]
1045    fn migrate_document_merges_panels_into_an_existing_quota_table() {
1046        // Legacy quota_panels + a `[usage.quota]` that has refresh_interval but no
1047        // panels: panels is added and the user's refresh_interval survives.
1048        let text = "[usage]\nquota_panels = [\"claude\"]\n\n[usage.quota]\nrefresh_interval = 30\n";
1049        let migrated = migrate_text(text).unwrap().expect("legacy file changes");
1050        assert!(!migrated.contains("quota_panels"));
1051        let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
1052        assert_eq!(cfg.usage.quota.panels, vec!["claude".to_string()]);
1053        assert_eq!(cfg.usage.quota.refresh_interval, 30);
1054        assert!(migrate_text(&migrated).unwrap().is_none());
1055    }
1056
1057    #[test]
1058    fn migrate_document_drops_legacy_quota_panels_when_new_panels_present() {
1059        let text = "#:schema x\n[usage]\nquota_panels = [\"gemini\"]\n\n[usage.quota]\npanels = [\"claude\"]\n";
1060        let migrated = migrate_text(text).unwrap().expect("drops the legacy key");
1061        assert!(!migrated.contains("quota_panels"));
1062        let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
1063        assert_eq!(cfg.usage.quota.panels, vec!["claude".to_string()]);
1064    }
1065
1066    #[test]
1067    fn migrate_document_skips_an_inline_quota_child() {
1068        // An inline `quota = { ... }` under `[usage]` is left untouched (with its
1069        // legacy sibling) rather than risk losing data merging into it.
1070        let text =
1071            "#:schema x\n[usage]\nquota_panels = [\"claude\"]\nquota = { panels = [\"codex\"] }\n";
1072        assert!(migrate_text(text).unwrap().is_none());
1073    }
1074
1075    #[test]
1076    fn refresh_secs_clamps_zero_to_one() {
1077        let cfg = UsageConfig {
1078            refresh_interval: 0,
1079            quota: QuotaConfig {
1080                refresh_interval: 0,
1081                ..QuotaConfig::default()
1082            },
1083            ..UsageConfig::default()
1084        };
1085        assert_eq!(cfg.refresh_secs(), 1);
1086        assert_eq!(cfg.quota_refresh_secs(), 1);
1087    }
1088
1089    #[test]
1090    fn providers_default_all_enabled() {
1091        let p = ProvidersConfig::default();
1092        assert!(
1093            p.claude
1094                && p.codex
1095                && p.copilot
1096                && p.gemini
1097                && p.opencode
1098                && p.cursor
1099                && p.hermes
1100                && p.grok
1101        );
1102    }
1103
1104    #[test]
1105    fn committed_schema_matches_generated() {
1106        // Guards `vct.schema.json` against drift from the typed Config.
1107        // Regenerate with: `vct config schema > vct.schema.json`.
1108        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../vct.schema.json");
1109        let committed: Value =
1110            serde_json::from_str(&std::fs::read_to_string(path).expect("vct.schema.json exists"))
1111                .expect("vct.schema.json is valid JSON");
1112        assert_eq!(committed, json_schema());
1113    }
1114}