1use 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
35const SCHEMA_URL: &str =
39 "https://raw.githubusercontent.com/Mai0313/VibeCodingTracker/main/vct.schema.json";
40
41#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
60pub struct PerformanceConfig {
61 #[serde(default)]
64 pub scan_threads: usize,
65}
66
67impl PerformanceConfig {
68 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#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
99pub struct GeneralConfig {
100 #[serde(default)]
103 pub default_time_range: TimeRange,
104}
105
106#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
108pub struct UsageConfig {
109 #[serde(default)]
112 pub merge_models: bool,
113 #[serde(default = "default_refresh_secs")]
115 pub refresh_interval: u64,
116 #[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 pub fn refresh_secs(&self) -> u64 {
135 self.refresh_interval.max(1)
136 }
137
138 pub fn quota_refresh_secs(&self) -> u64 {
140 self.quota.refresh_interval.max(1)
141 }
142
143 pub fn shows_quota_panel(&self, provider: &str) -> bool {
145 quota_panel_selected(&self.quota.panels, provider)
146 }
147}
148
149#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
151pub struct QuotaConfig {
152 #[serde(default = "default_quota_panels")]
155 pub panels: Vec<String>,
156 #[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
171pub fn quota_panel_selected(panels: &[String], name: &str) -> bool {
176 panels.iter().any(|p| p.eq_ignore_ascii_case(name))
177}
178
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
181pub struct AnalysisConfig {
182 #[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 pub fn refresh_secs(&self) -> u64 {
198 self.refresh_interval.max(1)
199 }
200}
201
202#[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
248pub struct LoggingConfig {
249 #[serde(default)]
252 pub level: LogLevel,
253 #[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
270#[serde(rename_all = "lowercase")]
271pub enum LogLevel {
272 Off,
274 Error,
276 #[default]
278 Warn,
279 Info,
281 Debug,
283 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
310pub fn load() -> Config {
316 match get_cache_dir() {
317 Ok(dir) => load_in(&dir),
318 Err(_) => Config::default(),
319 }
320}
321
322pub 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 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 migrate_legacy(&mut config, &effective);
345 return config;
346 }
347 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
356pub enum MigrationStatus {
357 Created,
359 Migrated,
361 AlreadyCurrent,
363}
364
365pub 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
383pub 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
391fn 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 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
423fn 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
438fn migrate_document(doc: &mut DocumentMut) -> bool {
446 let schema = json_schema();
447 let mut changed = false;
448
449 if !has_schema_directive(doc) {
451 changed |= prepend_schema_directive(doc);
452 }
453
454 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 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
473fn has_schema_directive(doc: &DocumentMut) -> bool {
475 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
484fn 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
510fn 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
519fn 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 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 let existing = decor_prefix(old_key.leaf_decor());
543 apply_comment(table, "refresh_interval", &existing, schema, comment_path);
544 }
545 true
546}
547
548fn 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 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 let comment = decor_prefix(old_key.leaf_decor());
590
591 if usage.get("quota").and_then(Item::as_table).is_some() {
592 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
614fn panels_array(panels: &[String]) -> Array {
616 panels.iter().map(String::as_str).collect()
617}
618
619fn 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
652fn 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
668fn 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
679pub fn save_merge_models(enabled: bool) -> Result<()> {
681 save_merge_models_in(&get_cache_dir()?, enabled)
682}
683
684pub fn save_merge_models_in(dir: &Path, enabled: bool) -> Result<()> {
686 edit_in(dir, |doc| {
687 if !doc["usage"].is_table_like() {
694 doc["usage"] = Item::Table(Table::new());
695 }
696 doc["usage"]["merge_models"] = value(enabled);
697 })
698}
699
700fn 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
712pub 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
722pub 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
730fn 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
744fn 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 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
795fn 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
806fn 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 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 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 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 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 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 assert!(migrated.starts_with("#:schema "));
942 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 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 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(¤t).unwrap().is_none());
970 }
971
972 #[test]
973 fn migrate_document_adds_only_the_schema_directive_when_keys_are_current() {
974 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 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 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 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 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 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 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 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 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}