1use crate::models::TimeRange;
28use crate::utils::{get_cache_dir, resolve_paths, write_string_atomic};
29use anyhow::Result;
30use schemars::JsonSchema;
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33use std::path::Path;
34use toml_edit::{Array, DocumentMut, Item, Table, TableLike, value};
35
36const SCHEMA_URL: &str =
40 "https://raw.githubusercontent.com/Mai0313/VibeCodingTracker/main/vct.schema.json";
41
42const CONFIG_VERSION: u32 = 3;
47
48const PANELS_ADDED_BY_VERSION: &[(u32, &str)] = &[(2, "grok")];
55
56#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
58pub struct Config {
59 #[serde(default)]
60 pub general: GeneralConfig,
61 #[serde(default)]
62 pub usage: UsageConfig,
63 #[serde(default)]
64 pub analysis: AnalysisConfig,
65 #[serde(default)]
66 pub performance: PerformanceConfig,
67 #[serde(default)]
68 pub providers: ProvidersConfig,
69 #[serde(default)]
70 pub logging: LoggingConfig,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
75pub struct PerformanceConfig {
76 #[serde(default)]
79 pub scan_threads: usize,
80}
81
82impl PerformanceConfig {
83 pub fn resolved_scan_threads(&self) -> usize {
89 let available = std::thread::available_parallelism()
90 .map(std::num::NonZeroUsize::get)
91 .unwrap_or(1);
92 self.resolved_scan_threads_with(
93 available,
94 std::env::var("RAYON_NUM_THREADS").ok().as_deref(),
95 )
96 }
97
98 fn resolved_scan_threads_with(&self, available: usize, rayon_env: Option<&str>) -> usize {
99 let available = available.max(1);
100 let requested = if self.scan_threads > 0 {
101 self.scan_threads
102 } else {
103 rayon_env
104 .and_then(|value| value.parse::<usize>().ok())
105 .filter(|value| *value > 0)
106 .unwrap_or_else(|| available.min(2))
107 };
108 requested.clamp(1, available)
109 }
110}
111
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
114pub struct GeneralConfig {
115 #[serde(default)]
118 pub default_time_range: TimeRange,
119 #[serde(default = "default_true")]
121 pub auto_update: bool,
122 #[serde(
125 default = "legacy_config_version",
126 deserialize_with = "de_config_version"
127 )]
128 pub version: u32,
129}
130
131impl Default for GeneralConfig {
132 fn default() -> Self {
133 Self {
134 default_time_range: TimeRange::default(),
135 auto_update: true,
136 version: CONFIG_VERSION,
139 }
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
145pub struct UsageConfig {
146 #[serde(default)]
149 pub merge_models: bool,
150 #[serde(default = "default_refresh_secs")]
152 pub refresh_interval: u64,
153 #[serde(default)]
155 pub quota: QuotaConfig,
156}
157
158impl Default for UsageConfig {
159 fn default() -> Self {
160 Self {
161 merge_models: false,
162 refresh_interval: default_refresh_secs(),
163 quota: QuotaConfig::default(),
164 }
165 }
166}
167
168impl UsageConfig {
169 pub fn refresh_secs(&self) -> u64 {
172 self.refresh_interval.max(1)
173 }
174
175 pub fn quota_refresh_secs(&self) -> u64 {
177 self.quota.refresh_interval.max(1)
178 }
179
180 pub fn shows_quota_panel(&self, provider: &str) -> bool {
182 quota_panel_selected(&self.quota.panels, provider)
183 }
184}
185
186#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
188pub struct QuotaConfig {
189 #[serde(default = "default_quota_panels")]
193 pub panels: Vec<String>,
194 #[serde(default = "default_quota_refresh_secs")]
197 pub refresh_interval: u64,
198}
199
200impl Default for QuotaConfig {
201 fn default() -> Self {
202 Self {
203 panels: default_quota_panels(),
204 refresh_interval: default_quota_refresh_secs(),
205 }
206 }
207}
208
209pub fn quota_panel_selected(panels: &[String], name: &str) -> bool {
214 panels.iter().any(|p| p.eq_ignore_ascii_case(name))
215}
216
217#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
219pub struct AnalysisConfig {
220 #[serde(default = "default_refresh_secs")]
222 pub refresh_interval: u64,
223}
224
225impl Default for AnalysisConfig {
226 fn default() -> Self {
227 Self {
228 refresh_interval: default_refresh_secs(),
229 }
230 }
231}
232
233impl AnalysisConfig {
234 pub fn refresh_secs(&self) -> u64 {
236 self.refresh_interval.max(1)
237 }
238}
239
240#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
245pub struct ProvidersConfig {
246 #[serde(default = "default_true")]
247 pub claude: bool,
248 #[serde(default = "default_true")]
249 pub codex: bool,
250 #[serde(default = "default_true")]
251 pub copilot: bool,
252 #[serde(default = "default_true")]
253 pub gemini: bool,
254 #[serde(default = "default_true")]
255 pub opencode: bool,
256 #[serde(default = "default_true")]
257 pub cursor: bool,
258 #[serde(default = "default_true")]
259 pub hermes: bool,
260 #[serde(default = "default_true")]
261 pub grok: bool,
262}
263
264impl Default for ProvidersConfig {
265 fn default() -> Self {
266 Self {
267 claude: true,
268 codex: true,
269 copilot: true,
270 gemini: true,
271 opencode: true,
272 cursor: true,
273 hermes: true,
274 grok: true,
275 }
276 }
277}
278
279#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
286pub struct LoggingConfig {
287 #[serde(default)]
290 pub level: LogLevel,
291 #[serde(default = "default_log_retention_days")]
294 pub retention_days: u32,
295}
296
297impl Default for LoggingConfig {
298 fn default() -> Self {
299 Self {
300 level: LogLevel::default(),
301 retention_days: default_log_retention_days(),
302 }
303 }
304}
305
306#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
308#[serde(rename_all = "lowercase")]
309pub enum LogLevel {
310 Off,
312 Error,
314 #[default]
316 Warn,
317 Info,
319 Debug,
321 Trace,
323}
324
325fn default_true() -> bool {
326 true
327}
328
329fn default_log_retention_days() -> u32 {
330 7
331}
332
333fn default_refresh_secs() -> u64 {
334 10
335}
336
337fn default_quota_refresh_secs() -> u64 {
338 60
339}
340
341fn legacy_config_version() -> u32 {
343 1
344}
345
346fn de_config_version<'de, D>(deserializer: D) -> Result<u32, D::Error>
354where
355 D: serde::Deserializer<'de>,
356{
357 Ok(Value::deserialize(deserializer)
358 .ok()
359 .and_then(|v| v.as_u64())
360 .and_then(|n| u32::try_from(n).ok())
361 .unwrap_or_else(legacy_config_version))
362}
363
364fn default_quota_panels() -> Vec<String> {
365 ["claude", "codex", "copilot", "cursor", "grok"]
366 .iter()
367 .map(|s| s.to_string())
368 .collect()
369}
370
371pub fn load() -> Config {
377 match get_cache_dir() {
378 Ok(dir) => load_in(&dir),
379 Err(_) => Config::default(),
380 }
381}
382
383pub fn load_read_only() -> Config {
389 match resolve_paths() {
390 Ok(paths) => load_read_only_in(&paths.cache_dir),
391 Err(_) => Config::default(),
392 }
393}
394
395pub fn load_read_only_in(dir: &Path) -> Config {
397 let path = dir.join("config.toml");
398 let Ok(text) = std::fs::read_to_string(path) else {
399 return Config::default();
400 };
401 parse_config_text(&text)
402}
403
404pub fn load_in(dir: &Path) -> Config {
406 let path = dir.join("config.toml");
407 if path.exists() {
408 let Ok(text) = std::fs::read_to_string(&path) else {
409 return Config::default();
410 };
411 let effective = match migrate_text(&text) {
416 Ok(Some(migrated)) => {
417 let _ = write_string_atomic(&path, &migrated);
418 migrated
419 }
420 Ok(None) => text,
421 Err(_) => return Config::default(),
422 };
423 return parse_effective_config(&effective);
424 }
425 let text = default_document().to_string();
427 let _ = write_string_atomic(&path, &text);
428 toml_edit::de::from_str(&text).unwrap_or_default()
429}
430
431fn parse_config_text(text: &str) -> Config {
434 match migrate_text(text) {
435 Ok(Some(migrated)) => parse_effective_config(&migrated),
436 Ok(None) => parse_effective_config(text),
437 Err(_) => Config::default(),
438 }
439}
440
441fn parse_effective_config(text: &str) -> Config {
442 let mut config: Config = toml_edit::de::from_str(text).unwrap_or_default();
443 migrate_legacy(&mut config, text);
446 config
447}
448
449#[derive(Debug, Clone, Copy, PartialEq, Eq)]
452pub enum MigrationStatus {
453 Created,
455 Migrated,
457 AlreadyCurrent,
459}
460
461pub fn migrate_config_file(path: &Path) -> Result<MigrationStatus> {
465 if !path.exists() {
466 write_string_atomic(path, &default_document().to_string())?;
467 return Ok(MigrationStatus::Created);
468 }
469 let text = std::fs::read_to_string(path)?;
470 match migrate_text(&text)? {
471 Some(migrated) => {
472 write_string_atomic(path, &migrated)?;
473 Ok(MigrationStatus::Migrated)
474 }
475 None => Ok(MigrationStatus::AlreadyCurrent),
476 }
477}
478
479pub fn migrate_text(text: &str) -> Result<Option<String>> {
483 let mut doc: DocumentMut = text.parse()?;
484 Ok(migrate_document(&mut doc).then(|| doc.to_string()))
485}
486
487fn migrate_legacy(config: &mut Config, raw: &str) {
495 let Ok(doc) = raw.parse::<DocumentMut>() else {
496 return;
497 };
498 if let Some(usage) = doc.get("usage").and_then(Item::as_table_like) {
499 let has_new_panels = usage
502 .get("quota")
503 .and_then(Item::as_table_like)
504 .is_some_and(|q| q.contains_key("panels"));
505 if !has_new_panels && let Some(legacy) = usage.get("quota_panels").and_then(Item::as_array)
506 {
507 config.usage.quota.panels = legacy
508 .iter()
509 .filter_map(|v| v.as_str().map(str::to_string))
510 .collect();
511 }
512 migrate_refresh_secs(usage, &mut config.usage.refresh_interval);
513 }
514 if let Some(analysis) = doc.get("analysis").and_then(Item::as_table_like) {
515 migrate_refresh_secs(analysis, &mut config.analysis.refresh_interval);
516 }
517}
518
519fn migrate_refresh_secs(section: &dyn TableLike, target: &mut u64) {
524 if !section.contains_key("refresh_interval")
525 && let Some(secs) = section
526 .get("refresh_interval_secs")
527 .and_then(Item::as_integer)
528 && let Ok(v) = u64::try_from(secs)
529 {
530 *target = v;
531 }
532}
533
534fn migrate_document(doc: &mut DocumentMut) -> bool {
542 let schema = json_schema();
543 let mut changed = false;
544
545 if doc.get("usage").is_some_and(Item::is_table) {
550 if let Some(usage) = doc.get_mut("usage").and_then(Item::as_table_mut) {
551 changed |= rename_refresh_key(usage, &schema, &["usage", "refresh_interval"]);
552 }
553 changed |= migrate_quota_panels(doc, &schema);
554 }
555
556 if let Some(analysis) = doc.get_mut("analysis").and_then(Item::as_table_mut) {
558 changed |= rename_refresh_key(analysis, &schema, &["analysis", "refresh_interval"]);
559 }
560
561 changed |= backfill_new_quota_panels(doc, &schema);
564
565 if !has_schema_directive(doc) {
570 changed |= prepend_schema_directive(doc);
571 }
572
573 changed
574}
575
576fn backfill_new_quota_panels(doc: &mut DocumentMut, schema: &Value) -> bool {
588 let from_version = match doc.get("general") {
589 Some(item) => {
590 let Some(general) = item.as_table() else {
591 return false;
593 };
594 general
595 .get("version")
596 .and_then(Item::as_integer)
597 .and_then(|v| u32::try_from(v).ok())
598 .unwrap_or_else(legacy_config_version)
599 }
600 None => legacy_config_version(),
601 };
602 if from_version >= CONFIG_VERSION {
603 return false;
604 }
605
606 if panels_out_of_reach(doc) {
610 return backfill_auto_update(doc, schema);
614 }
615
616 let mut changed = false;
617 if let Some(panels) = doc
618 .get_mut("usage")
619 .and_then(Item::as_table_mut)
620 .and_then(|usage| usage.get_mut("quota"))
621 .and_then(Item::as_table_mut)
622 .and_then(|quota| quota.get_mut("panels"))
623 .and_then(Item::as_array_mut)
624 .filter(|panels| !panels.is_empty())
626 {
627 let missing: Vec<&str> = PANELS_ADDED_BY_VERSION
628 .iter()
629 .filter(|(version, _)| from_version < *version)
630 .map(|(_, name)| *name)
631 .filter(|name| {
632 !panels
633 .iter()
634 .filter_map(|p| p.as_str())
635 .any(|p| p.eq_ignore_ascii_case(name))
636 })
637 .collect();
638 for name in missing {
639 panels.push(name);
640 changed = true;
641 }
642 }
643
644 changed |= stamp_config_version(doc, schema);
645 changed |= backfill_auto_update(doc, schema);
646 changed
647}
648
649fn backfill_auto_update(doc: &mut DocumentMut, schema: &Value) -> bool {
655 let Some(general) = doc.get_mut("general").and_then(Item::as_table_mut) else {
656 return false;
657 };
658 if !general.contains_key("auto_update") {
659 general.insert("auto_update", value(true));
660 apply_comment(
661 general,
662 "auto_update",
663 "",
664 schema,
665 &["general", "auto_update"],
666 );
667 return true;
668 }
669 false
670}
671
672fn panels_out_of_reach(doc: &DocumentMut) -> bool {
679 let Some(usage) = doc.get("usage") else {
680 return false;
681 };
682 let holds_panels = |table: &dyn TableLike| {
683 table.contains_key("quota_panels")
684 || table
685 .get("quota")
686 .and_then(Item::as_table_like)
687 .is_some_and(|quota| quota.contains_key("panels"))
688 };
689 match usage.as_table() {
690 None => usage.as_table_like().is_some_and(holds_panels),
692 Some(usage) => {
693 let inline_quota = usage
695 .get("quota")
696 .filter(|quota| !quota.is_table())
697 .and_then(Item::as_table_like)
698 .is_some_and(|quota| quota.contains_key("panels"));
699 inline_quota || usage.contains_key("quota_panels")
703 }
704 }
705}
706
707fn stamp_config_version(doc: &mut DocumentMut, schema: &Value) -> bool {
710 let mut changed = false;
711 if doc.get("general").is_none() {
712 let mut table = Table::new();
713 table.set_implicit(false);
714 if let Some(desc) = schema_description(schema, &["general"]) {
715 table
716 .decor_mut()
717 .set_prefix(format!("\n{}", comment_block(&desc)));
718 }
719 doc.insert("general", Item::Table(table));
720 changed = true;
721 }
722 let Some(general) = doc.get_mut("general").and_then(Item::as_table_mut) else {
723 return changed;
724 };
725 let had_key = general.contains_key("version");
726 let version_is_current = general
727 .get("version")
728 .and_then(Item::as_integer)
729 .is_some_and(|version| version == i64::from(CONFIG_VERSION));
730 general.insert("version", value(i64::from(CONFIG_VERSION)));
731 if !had_key {
732 apply_comment(general, "version", "", schema, &["general", "version"]);
733 }
734 changed || !version_is_current
735}
736
737fn has_schema_directive(doc: &DocumentMut) -> bool {
739 doc.to_string()
743 .lines()
744 .find(|line| !line.trim().is_empty())
745 .is_some_and(|line| line.trim_start().starts_with("#:schema"))
746}
747
748fn prepend_schema_directive(doc: &mut DocumentMut) -> bool {
751 let directive = format!("#:schema {SCHEMA_URL}\n");
752 let root = doc.as_table_mut();
753 let Some(first_key) = root.iter().next().map(|(k, _)| k.to_string()) else {
754 return false;
755 };
756 if let Some(item) = root.get_mut(&first_key)
757 && let Some(table) = item.as_table_mut()
758 {
759 let existing = decor_prefix(table.decor());
760 table
761 .decor_mut()
762 .set_prefix(format!("{directive}{existing}"));
763 return true;
764 }
765 if let Some(mut km) = root.key_mut(&first_key) {
766 let existing = decor_prefix(km.leaf_decor());
767 km.leaf_decor_mut()
768 .set_prefix(format!("{directive}{existing}"));
769 return true;
770 }
771 false
772}
773
774fn decor_prefix(decor: &toml_edit::Decor) -> String {
776 decor
777 .prefix()
778 .and_then(|p| p.as_str())
779 .unwrap_or("")
780 .to_string()
781}
782
783fn rename_refresh_key(table: &mut Table, schema: &Value, comment_path: &[&str]) -> bool {
792 if !table.contains_key("refresh_interval_secs") {
793 return false;
794 }
795 if table.contains_key("refresh_interval") {
797 table.remove("refresh_interval_secs");
798 return true;
799 }
800 let Some((old_key, item)) = table.remove_entry("refresh_interval_secs") else {
801 return false;
802 };
803 if let Some(secs) = item.as_integer().and_then(|n| u64::try_from(n).ok()) {
804 table.insert("refresh_interval", value(secs as i64));
805 let existing = decor_prefix(old_key.leaf_decor());
807 apply_comment(table, "refresh_interval", &existing, schema, comment_path);
808 }
809 true
810}
811
812fn migrate_quota_panels(doc: &mut DocumentMut, schema: &Value) -> bool {
819 let Some(usage) = doc.get("usage").and_then(Item::as_table) else {
820 return false;
821 };
822 if !usage.contains_key("quota_panels") {
823 return false;
824 }
825 if usage.get("quota").is_some_and(Item::is_inline_table) {
826 return false;
827 }
828 let has_new_panels = usage
829 .get("quota")
830 .and_then(Item::as_table_like)
831 .is_some_and(|q| q.contains_key("panels"));
832
833 let usage = doc
834 .get_mut("usage")
835 .and_then(Item::as_table_mut)
836 .expect("usage table present");
837 let Some((old_key, old_item)) = usage.remove_entry("quota_panels") else {
838 return false;
839 };
840 if has_new_panels {
841 return true;
843 }
844 let legacy_panels: Vec<String> = old_item
845 .as_array()
846 .map(|a| {
847 a.iter()
848 .filter_map(|v| v.as_str().map(str::to_string))
849 .collect()
850 })
851 .unwrap_or_default();
852 let comment = decor_prefix(old_key.leaf_decor());
854
855 if usage.get("quota").and_then(Item::as_table).is_some() {
856 let quota = usage
858 .get_mut("quota")
859 .and_then(Item::as_table_mut)
860 .expect("quota table present");
861 quota.insert("panels", value(panels_array(&legacy_panels)));
862 apply_comment(
863 quota,
864 "panels",
865 &comment,
866 schema,
867 &["usage", "quota", "panels"],
868 );
869 } else {
870 usage.insert(
871 "quota",
872 Item::Table(build_quota_table(&legacy_panels, &comment, schema)),
873 );
874 }
875 true
876}
877
878fn panels_array(panels: &[String]) -> Array {
880 panels.iter().map(String::as_str).collect()
881}
882
883fn build_quota_table(panels: &[String], panels_comment: &str, schema: &Value) -> Table {
887 let mut table = Table::new();
888 table.set_implicit(false);
889 if let Some(desc) = schema_description(schema, &["usage", "quota"]) {
890 table
891 .decor_mut()
892 .set_prefix(format!("\n{}", comment_block(&desc)));
893 }
894 table.insert("panels", value(panels_array(panels)));
895 apply_comment(
896 &mut table,
897 "panels",
898 panels_comment,
899 schema,
900 &["usage", "quota", "panels"],
901 );
902 table.insert(
903 "refresh_interval",
904 value(default_quota_refresh_secs() as i64),
905 );
906 apply_comment(
907 &mut table,
908 "refresh_interval",
909 "",
910 schema,
911 &["usage", "quota", "refresh_interval"],
912 );
913 table
914}
915
916fn apply_comment(table: &mut Table, key: &str, existing: &str, schema: &Value, path: &[&str]) {
920 let prefix = if existing.trim().is_empty() {
921 schema_description(schema, path).map(|desc| format!("\n{}", comment_block(&desc)))
922 } else {
923 Some(existing.to_string())
924 };
925 if let Some(prefix) = prefix
926 && let Some(mut km) = table.key_mut(key)
927 {
928 km.leaf_decor_mut().set_prefix(prefix);
929 }
930}
931
932fn schema_description(schema: &Value, path: &[&str]) -> Option<String> {
934 let mut cur = schema;
935 for key in path {
936 cur = cur.get("properties")?.get(key)?;
937 }
938 cur.get("description")
939 .and_then(Value::as_str)
940 .map(str::to_string)
941}
942
943pub fn save_merge_models(enabled: bool) -> Result<()> {
945 save_merge_models_in(&get_cache_dir()?, enabled)
946}
947
948pub fn save_merge_models_in(dir: &Path, enabled: bool) -> Result<()> {
950 edit_in(dir, |doc| {
951 if !doc["usage"].is_table_like() {
958 doc["usage"] = Item::Table(Table::new());
959 }
960 doc["usage"]["merge_models"] = value(enabled);
961 })
962}
963
964fn edit_in(dir: &Path, mutate: impl FnOnce(&mut DocumentMut)) -> Result<()> {
967 let path = dir.join("config.toml");
968 let mut doc = std::fs::read_to_string(&path)
969 .ok()
970 .and_then(|text| text.parse::<DocumentMut>().ok())
971 .unwrap_or_else(default_document);
972 mutate(&mut doc);
973 write_string_atomic(&path, &doc.to_string())
974}
975
976pub fn json_schema() -> Value {
980 let settings =
981 schemars::generate::SchemaSettings::draft2020_12().with(|s| s.inline_subschemas = true);
982 let schema = settings.into_generator().into_root_schema_for::<Config>();
983 serde_json::to_value(&schema).expect("config schema serializes to JSON")
984}
985
986pub fn schema_json() -> String {
989 let mut s = serde_json::to_string_pretty(&json_schema()).expect("schema serializes");
990 s.push('\n');
991 s
992}
993
994fn default_document() -> DocumentMut {
999 let mut doc: DocumentMut = toml_edit::ser::to_string(&Config::default())
1000 .expect("default config serializes to TOML")
1001 .parse()
1002 .expect("serialized default config is valid TOML");
1003 let schema = json_schema();
1004 annotate_table(doc.as_table_mut(), &schema, true);
1005 doc
1006}
1007
1008fn annotate_table(table: &mut Table, schema: &Value, is_root: bool) {
1012 let props = schema
1013 .get("properties")
1014 .and_then(Value::as_object)
1015 .cloned()
1016 .unwrap_or_default();
1017 let keys: Vec<String> = table.iter().map(|(k, _)| k.to_string()).collect();
1018 for (idx, key) in keys.iter().enumerate() {
1019 let field_schema = props.get(key);
1020 let desc = field_schema
1021 .and_then(|s| s.get("description"))
1022 .and_then(Value::as_str);
1023 let is_table = table
1024 .get(key)
1025 .is_some_and(|i| i.is_table() || i.is_inline_table());
1026 if is_table {
1027 let field_schema = field_schema.cloned();
1028 if let Some(mut km) = table.key_mut(key) {
1031 km.leaf_decor_mut().set_suffix("");
1032 }
1033 if let Some(item) = table.get_mut(key)
1034 && let Some(sub) = as_standard_table(item)
1035 {
1036 let mut prefix = String::new();
1037 if is_root && idx == 0 {
1038 prefix.push_str(&format!("#:schema {SCHEMA_URL}\n\n"));
1039 } else {
1040 prefix.push('\n');
1041 }
1042 if let Some(d) = desc {
1043 prefix.push_str(&comment_block(d));
1044 }
1045 sub.decor_mut().set_prefix(prefix);
1046 if let Some(fs) = field_schema {
1047 annotate_table(sub, &fs, false);
1048 }
1049 }
1050 } else if let Some(d) = desc
1051 && let Some(mut km) = table.key_mut(key)
1052 {
1053 km.leaf_decor_mut()
1054 .set_prefix(format!("\n{}", comment_block(d)));
1055 }
1056 }
1057}
1058
1059fn as_standard_table(item: &mut Item) -> Option<&mut Table> {
1062 if item.is_inline_table()
1063 && let Some(inline) = item.as_inline_table().cloned()
1064 {
1065 *item = Item::Table(inline.into_table());
1066 }
1067 item.as_table_mut()
1068}
1069
1070fn comment_block(desc: &str) -> String {
1073 desc.lines()
1074 .map(|line| {
1075 if line.is_empty() {
1076 "#\n".to_string()
1077 } else {
1078 format!("# {line}\n")
1079 }
1080 })
1081 .collect()
1082}
1083
1084#[cfg(test)]
1085mod tests {
1086 use super::*;
1087
1088 #[test]
1089 fn generated_template_parses_to_expected_defaults() {
1090 let text = default_document().to_string();
1091 let cfg: Config = toml_edit::de::from_str(&text).unwrap();
1092 assert_eq!(cfg, Config::default());
1093 assert!(text.starts_with(&format!("#:schema {SCHEMA_URL}")));
1094 assert_eq!(cfg.general.default_time_range, TimeRange::All);
1095 assert_eq!(cfg.usage.quota.panels, default_quota_panels());
1096 assert!(cfg.usage.shows_quota_panel("cursor"));
1097 assert!(!cfg.usage.merge_models);
1098 assert_eq!(cfg.usage.refresh_interval, 10);
1099 assert_eq!(cfg.usage.quota.refresh_interval, 60);
1100 assert_eq!(cfg.analysis.refresh_interval, 10);
1101 assert_eq!(cfg.performance.scan_threads, 0);
1102 assert_eq!(cfg.providers, ProvidersConfig::default());
1103 assert!(cfg.providers.cursor);
1104 assert!(cfg.providers.grok);
1105 assert_eq!(cfg.logging.level, LogLevel::Warn);
1106 assert_eq!(cfg.logging.retention_days, 7);
1107 }
1108
1109 #[test]
1110 fn missing_sections_use_defaults() {
1111 let cfg: Config = toml_edit::de::from_str("").unwrap();
1114 assert_eq!(cfg.usage.quota.panels, default_quota_panels());
1115 assert!(cfg.providers.cursor);
1116 assert!(cfg.providers.grok);
1117 assert_eq!(cfg.usage.refresh_secs(), 10);
1118 assert_eq!(cfg.usage.quota_refresh_secs(), 60);
1119 assert_eq!(cfg.logging, LoggingConfig::default());
1122 assert_eq!(cfg.performance, PerformanceConfig::default());
1123 }
1124
1125 #[test]
1126 fn scan_threads_resolve_config_then_env_then_auto() {
1127 let auto = PerformanceConfig { scan_threads: 0 };
1128 assert_eq!(auto.resolved_scan_threads_with(16, None), 2);
1129 assert_eq!(auto.resolved_scan_threads_with(1, None), 1);
1130 assert_eq!(auto.resolved_scan_threads_with(16, Some("7")), 7);
1131 assert_eq!(auto.resolved_scan_threads_with(4, Some("99")), 4);
1132 assert_eq!(auto.resolved_scan_threads_with(16, Some("0")), 2);
1133 assert_eq!(auto.resolved_scan_threads_with(16, Some("invalid")), 2);
1134
1135 let configured = PerformanceConfig { scan_threads: 12 };
1136 assert_eq!(configured.resolved_scan_threads_with(8, Some("3")), 8);
1137 }
1138
1139 #[test]
1140 fn partial_usage_section_keeps_quota_default() {
1141 let cfg: Config = toml_edit::de::from_str("[usage]\nmerge_models = true\n").unwrap();
1142 assert!(cfg.usage.merge_models);
1143 assert_eq!(cfg.usage.quota.panels, default_quota_panels());
1144 assert_eq!(cfg.usage.quota_refresh_secs(), 60);
1145 }
1146
1147 #[test]
1148 fn quota_panels_can_be_narrowed_or_emptied() {
1149 let cfg: Config =
1150 toml_edit::de::from_str("[usage.quota]\npanels = [\"claude\"]\n").unwrap();
1151 assert!(cfg.usage.shows_quota_panel("claude"));
1152 assert!(!cfg.usage.shows_quota_panel("cursor"));
1153
1154 let empty: Config = toml_edit::de::from_str("[usage.quota]\npanels = []\n").unwrap();
1155 assert!(!empty.usage.shows_quota_panel("claude"));
1156 }
1157
1158 #[test]
1159 fn refresh_interval_reads_legacy_secs() {
1160 let text = "[usage]\nrefresh_interval_secs = 5\n[analysis]\nrefresh_interval_secs = 7\n";
1162 let mut cfg: Config = toml_edit::de::from_str(text).unwrap();
1163 migrate_legacy(&mut cfg, text);
1164 assert_eq!(cfg.usage.refresh_secs(), 5);
1165 assert_eq!(cfg.analysis.refresh_secs(), 7);
1166 }
1167
1168 #[test]
1169 fn coexisting_refresh_keys_preserve_the_rest_of_the_config() {
1170 let text = "[general]\ndefault_time_range = \"weekly\"\n[usage]\nrefresh_interval = 5\nrefresh_interval_secs = 10\n[providers]\ncursor = false\n";
1175 let mut cfg: Config = toml_edit::de::from_str(text).unwrap();
1176 migrate_legacy(&mut cfg, text);
1177 assert_eq!(cfg.usage.refresh_secs(), 5);
1178 assert_eq!(cfg.general.default_time_range, TimeRange::Weekly);
1179 assert!(!cfg.providers.cursor);
1180 }
1181
1182 #[test]
1183 fn migrates_legacy_quota_panels() {
1184 let text = "[usage]\nquota_panels = [\"claude\"]\n";
1186 let mut cfg: Config = toml_edit::de::from_str(text).unwrap();
1187 migrate_legacy(&mut cfg, text);
1188 assert_eq!(cfg.usage.quota.panels, vec!["claude".to_string()]);
1189 assert!(!cfg.usage.shows_quota_panel("cursor"));
1190 }
1191
1192 #[test]
1193 fn new_quota_panels_win_over_legacy() {
1194 let text = "[usage]\nquota_panels = [\"claude\"]\n[usage.quota]\npanels = []\n";
1195 let mut cfg: Config = toml_edit::de::from_str(text).unwrap();
1196 migrate_legacy(&mut cfg, text);
1197 assert!(cfg.usage.quota.panels.is_empty());
1198 }
1199
1200 #[test]
1201 fn migrate_document_upgrades_a_full_legacy_file() {
1202 let legacy = "# old header\n\n[usage]\nmerge_models = false\nquota_panels = [\"claude\", \"codex\"]\nrefresh_interval_secs = 15\n\n[analysis]\nrefresh_interval_secs = 20\n";
1203 let migrated = migrate_text(legacy).unwrap().expect("legacy file changes");
1204 assert!(migrated.starts_with("#:schema "));
1206 assert!(!migrated.contains("quota_panels"));
1208 assert!(!migrated.contains("refresh_interval_secs"));
1209 assert!(migrated.contains("[usage.quota]"));
1210 assert!(migrated.contains("panels = [\"claude\", \"codex\", \"grok\"]"));
1213 let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
1214 assert_eq!(cfg.usage.refresh_interval, 15);
1215 assert_eq!(cfg.analysis.refresh_interval, 20);
1216 assert_eq!(
1217 cfg.usage.quota.panels,
1218 vec![
1219 "claude".to_string(),
1220 "codex".to_string(),
1221 "grok".to_string()
1222 ]
1223 );
1224 assert_eq!(cfg.usage.quota.refresh_interval, 60);
1225 assert_eq!(cfg.general.version, CONFIG_VERSION);
1226 }
1227
1228 #[test]
1229 fn migrate_document_is_idempotent() {
1230 let legacy = "[usage]\nquota_panels = [\"claude\"]\nrefresh_interval_secs = 15\n";
1231 let once = migrate_text(legacy).unwrap().expect("first pass changes");
1232 assert!(migrate_text(&once).unwrap().is_none());
1234 }
1235
1236 #[test]
1237 fn migrate_document_noops_on_the_generated_default() {
1238 let current = default_document().to_string();
1239 assert!(migrate_text(¤t).unwrap().is_none());
1240 }
1241
1242 #[test]
1243 fn migrate_document_adds_only_the_schema_directive_when_keys_are_current() {
1244 let text = "[general]\nversion = 3\n\n[usage]\nrefresh_interval = 10\n\n[usage.quota]\npanels = [\"claude\"]\n";
1247 let migrated = migrate_text(text).unwrap().expect("adds #:schema");
1248 assert!(migrated.starts_with("#:schema "));
1249 assert!(migrated.contains("refresh_interval = 10"));
1250 assert!(migrated.contains("panels = [\"claude\"]"));
1251 }
1252
1253 #[test]
1254 fn migrate_document_drops_stale_refresh_key_when_both_present() {
1255 let text = "#:schema x\n[usage]\nrefresh_interval = 5\nrefresh_interval_secs = 10\n";
1256 let migrated = migrate_text(text).unwrap().expect("drops the legacy key");
1257 assert!(!migrated.contains("refresh_interval_secs"));
1258 let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
1259 assert_eq!(cfg.usage.refresh_interval, 5);
1260 }
1261
1262 #[test]
1263 fn migrate_document_leaves_an_inline_usage_table_to_the_read_shim() {
1264 let text = "usage = { quota_panels = [\"claude\"], refresh_interval_secs = 30 }\n";
1267 let migrated = migrate_text(text).unwrap().expect("adds #:schema");
1268 assert!(migrated.starts_with("#:schema "));
1269 assert!(migrated.contains("quota_panels"));
1270 assert!(migrate_text(&migrated).unwrap().is_none());
1272 }
1273
1274 #[test]
1275 fn migrate_document_leaves_an_inline_general_table_unmodified() {
1276 let text = "#:schema x\ngeneral = { default_time_range = \"weekly\", version = 2 }\n";
1277 assert!(migrate_text(text).unwrap().is_none());
1278 let cfg = parse_config_text(text);
1279 assert_eq!(cfg.general.default_time_range, TimeRange::Weekly);
1280 assert!(cfg.general.auto_update);
1281 }
1282
1283 #[test]
1284 fn migrate_text_errors_on_malformed_toml() {
1285 assert!(migrate_text("= not valid").is_err());
1286 }
1287
1288 #[test]
1289 fn migrate_document_drops_a_malformed_legacy_refresh_value() {
1290 let text = "[general]\ndefault_time_range = \"weekly\"\n[usage]\nrefresh_interval_secs = -5\n[providers]\ncursor = false\n";
1294 let migrated = migrate_text(text).unwrap().expect("legacy file changes");
1295 assert!(!migrated.contains("refresh_interval = -5"));
1296 assert!(!migrated.contains("refresh_interval_secs"));
1297 let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
1298 assert_eq!(cfg.general.default_time_range, TimeRange::Weekly);
1299 assert!(!cfg.providers.cursor);
1300 assert_eq!(cfg.usage.refresh_interval, 10);
1301 assert!(migrate_text(&migrated).unwrap().is_none());
1303 }
1304
1305 #[test]
1306 fn migrate_document_preserves_a_hand_added_comment_on_a_renamed_key() {
1307 let text = "[usage]\n# keep me\nrefresh_interval_secs = 15\n";
1308 let migrated = migrate_text(text).unwrap().expect("legacy file changes");
1309 assert!(migrated.contains("# keep me"));
1310 assert!(migrated.contains("refresh_interval = 15"));
1311 }
1312
1313 #[test]
1314 fn migrate_document_adds_the_directive_despite_a_buried_schema_line() {
1315 let text = "[usage]\nquota_panels = [\"claude\"]\nnote = \"\"\"\n#:schema fake\n\"\"\"\n";
1318 let migrated = migrate_text(text)
1319 .unwrap()
1320 .expect("adds the real directive");
1321 assert!(migrated.starts_with("#:schema https://"));
1322 }
1323
1324 #[test]
1325 fn migrate_document_merges_panels_into_an_existing_quota_table() {
1326 let text = "[usage]\nquota_panels = [\"claude\"]\n\n[usage.quota]\nrefresh_interval = 30\n";
1329 let migrated = migrate_text(text).unwrap().expect("legacy file changes");
1330 assert!(!migrated.contains("quota_panels"));
1331 let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
1332 assert_eq!(
1333 cfg.usage.quota.panels,
1334 vec!["claude".to_string(), "grok".to_string()]
1335 );
1336 assert_eq!(cfg.usage.quota.refresh_interval, 30);
1337 assert!(migrate_text(&migrated).unwrap().is_none());
1338 }
1339
1340 #[test]
1341 fn migrate_document_drops_legacy_quota_panels_when_new_panels_present() {
1342 let text = "#:schema x\n[usage]\nquota_panels = [\"gemini\"]\n\n[usage.quota]\npanels = [\"claude\"]\n";
1343 let migrated = migrate_text(text).unwrap().expect("drops the legacy key");
1344 assert!(!migrated.contains("quota_panels"));
1345 let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
1346 assert_eq!(
1347 cfg.usage.quota.panels,
1348 vec!["claude".to_string(), "grok".to_string()]
1349 );
1350 }
1351
1352 #[test]
1353 fn migrate_document_skips_an_inline_quota_child() {
1354 let text =
1358 "#:schema x\n[usage]\nquota_panels = [\"claude\"]\nquota = { panels = [\"codex\"] }\n";
1359 assert!(migrate_text(text).unwrap().is_none());
1360 assert!(panels_out_of_reach(&text.parse::<DocumentMut>().unwrap()));
1361 }
1362
1363 #[test]
1366 fn backfilled_panel_is_added_once_and_stays_removed() {
1367 let text = "#:schema x\n[general]\ndefault_time_range = \"all\"\n\n[usage.quota]\npanels = [\"claude\", \"codex\"]\n";
1368 let migrated = migrate_text(text)
1369 .unwrap()
1370 .expect("version 1 file upgrades");
1371 assert!(migrated.contains("panels = [\"claude\", \"codex\", \"grok\"]"));
1372 assert!(migrated.contains("version = 3"));
1373 assert!(migrate_text(&migrated).unwrap().is_none());
1374
1375 let pruned = migrated.replace(
1377 "[\"claude\", \"codex\", \"grok\"]",
1378 "[\"claude\", \"codex\"]",
1379 );
1380 assert!(migrate_text(&pruned).unwrap().is_none());
1381 let cfg: Config = toml_edit::de::from_str(&pruned).unwrap();
1382 assert!(!cfg.usage.shows_quota_panel("grok"));
1383 }
1384
1385 #[test]
1388 fn backfill_leaves_previously_removed_panels_alone() {
1389 let text = "#:schema x\n[usage.quota]\npanels = [\"claude\"]\n";
1390 let migrated = migrate_text(text)
1391 .unwrap()
1392 .expect("version 1 file upgrades");
1393 let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
1394 assert_eq!(
1395 cfg.usage.quota.panels,
1396 vec!["claude".to_string(), "grok".to_string()],
1397 "codex / copilot / cursor were removed on purpose and stay removed"
1398 );
1399 }
1400
1401 #[test]
1402 fn backfill_never_reopens_a_band_turned_off() {
1403 let text = "#:schema x\n[usage.quota]\npanels = []\n";
1405 let migrated = migrate_text(text).unwrap().expect("the marker is stamped");
1406 let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
1407 assert!(cfg.usage.quota.panels.is_empty());
1408 assert_eq!(cfg.general.version, CONFIG_VERSION);
1409 }
1410
1411 #[test]
1412 fn backfill_stamps_a_file_with_no_general_section() {
1413 let text = "#:schema x\n[providers]\ncursor = false\n";
1416 let migrated = migrate_text(text).unwrap().expect("the marker is stamped");
1417 let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
1418 assert_eq!(cfg.general.version, CONFIG_VERSION);
1419 assert!(!cfg.providers.cursor);
1420 assert!(cfg.usage.shows_quota_panel("grok"));
1422 assert!(migrate_text(&migrated).unwrap().is_none());
1423 }
1424
1425 #[test]
1426 fn an_empty_file_reaches_the_current_layout_in_one_pass() {
1427 for text in ["", "# just a comment\n"] {
1431 let migrated = migrate_text(text)
1432 .unwrap()
1433 .expect("an empty file is stamped");
1434 assert!(migrated.starts_with("#:schema "));
1435 assert!(migrated.contains("version = 3"));
1436 assert!(
1437 migrate_text(&migrated).unwrap().is_none(),
1438 "one pass must be enough"
1439 );
1440 }
1441 }
1442
1443 #[test]
1444 fn a_panel_list_that_could_not_be_moved_is_not_marked_upgraded() {
1445 let text =
1449 "#:schema x\n[usage]\nquota_panels = [\"claude\"]\nquota = { refresh_interval = 30 }\n";
1450 assert!(migrate_text(text).unwrap().is_none());
1451 assert!(panels_out_of_reach(&text.parse::<DocumentMut>().unwrap()));
1452 }
1453
1454 #[test]
1455 fn a_hand_edited_version_value_never_resets_the_config() {
1456 for bad in ["\"2\"", "2.5", "true", "-1"] {
1459 let text = format!(
1460 "[general]\nversion = {bad}\ndefault_time_range = \"weekly\"\n[providers]\ncursor = false\n"
1461 );
1462 let cfg: Config = toml_edit::de::from_str(&text).expect("document still parses");
1463 assert_eq!(cfg.general.version, legacy_config_version(), "bad = {bad}");
1464 assert_eq!(cfg.general.default_time_range, TimeRange::Weekly);
1465 assert!(!cfg.providers.cursor);
1466 let migrated = migrate_text(&text)
1468 .unwrap()
1469 .expect("the marker is repaired");
1470 let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
1471 assert_eq!(cfg.general.version, CONFIG_VERSION);
1472 }
1473 }
1474
1475 #[test]
1476 fn every_backfilled_panel_ships_by_the_current_version() {
1477 for (version, name) in PANELS_ADDED_BY_VERSION {
1478 assert!(
1479 *version <= CONFIG_VERSION,
1480 "{name} is scheduled for a version this build never stamps"
1481 );
1482 assert!(
1483 default_quota_panels().iter().any(|p| p == name),
1484 "{name} is back-filled but is not a current default panel"
1485 );
1486 }
1487 }
1488
1489 #[test]
1490 fn refresh_secs_clamps_zero_to_one() {
1491 let cfg = UsageConfig {
1492 refresh_interval: 0,
1493 quota: QuotaConfig {
1494 refresh_interval: 0,
1495 ..QuotaConfig::default()
1496 },
1497 ..UsageConfig::default()
1498 };
1499 assert_eq!(cfg.refresh_secs(), 1);
1500 assert_eq!(cfg.quota_refresh_secs(), 1);
1501 }
1502
1503 #[test]
1504 fn providers_default_all_enabled() {
1505 let p = ProvidersConfig::default();
1506 assert!(
1507 p.claude
1508 && p.codex
1509 && p.copilot
1510 && p.gemini
1511 && p.opencode
1512 && p.cursor
1513 && p.hermes
1514 && p.grok
1515 );
1516 }
1517
1518 #[test]
1519 fn committed_schema_matches_generated() {
1520 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../vct.schema.json");
1523 let committed: Value =
1524 serde_json::from_str(&std::fs::read_to_string(path).expect("vct.schema.json exists"))
1525 .expect("vct.schema.json is valid JSON");
1526 assert_eq!(committed, json_schema());
1527 }
1528}