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
41const CONFIG_VERSION: u32 = 2;
46
47const PANELS_ADDED_BY_VERSION: &[(u32, &str)] = &[(2, "grok")];
54
55#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
57pub struct Config {
58 #[serde(default)]
59 pub general: GeneralConfig,
60 #[serde(default)]
61 pub usage: UsageConfig,
62 #[serde(default)]
63 pub analysis: AnalysisConfig,
64 #[serde(default)]
65 pub performance: PerformanceConfig,
66 #[serde(default)]
67 pub providers: ProvidersConfig,
68 #[serde(default)]
69 pub logging: LoggingConfig,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
74pub struct PerformanceConfig {
75 #[serde(default)]
78 pub scan_threads: usize,
79}
80
81impl PerformanceConfig {
82 pub fn resolved_scan_threads(&self) -> usize {
88 let available = std::thread::available_parallelism()
89 .map(std::num::NonZeroUsize::get)
90 .unwrap_or(1);
91 self.resolved_scan_threads_with(
92 available,
93 std::env::var("RAYON_NUM_THREADS").ok().as_deref(),
94 )
95 }
96
97 fn resolved_scan_threads_with(&self, available: usize, rayon_env: Option<&str>) -> usize {
98 let available = available.max(1);
99 let requested = if self.scan_threads > 0 {
100 self.scan_threads
101 } else {
102 rayon_env
103 .and_then(|value| value.parse::<usize>().ok())
104 .filter(|value| *value > 0)
105 .unwrap_or_else(|| available.min(2))
106 };
107 requested.clamp(1, available)
108 }
109}
110
111#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
113pub struct GeneralConfig {
114 #[serde(default)]
117 pub default_time_range: TimeRange,
118 #[serde(
121 default = "legacy_config_version",
122 deserialize_with = "de_config_version"
123 )]
124 pub version: u32,
125}
126
127impl Default for GeneralConfig {
128 fn default() -> Self {
129 Self {
130 default_time_range: TimeRange::default(),
131 version: CONFIG_VERSION,
134 }
135 }
136}
137
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
140pub struct UsageConfig {
141 #[serde(default)]
144 pub merge_models: bool,
145 #[serde(default = "default_refresh_secs")]
147 pub refresh_interval: u64,
148 #[serde(default)]
150 pub quota: QuotaConfig,
151}
152
153impl Default for UsageConfig {
154 fn default() -> Self {
155 Self {
156 merge_models: false,
157 refresh_interval: default_refresh_secs(),
158 quota: QuotaConfig::default(),
159 }
160 }
161}
162
163impl UsageConfig {
164 pub fn refresh_secs(&self) -> u64 {
167 self.refresh_interval.max(1)
168 }
169
170 pub fn quota_refresh_secs(&self) -> u64 {
172 self.quota.refresh_interval.max(1)
173 }
174
175 pub fn shows_quota_panel(&self, provider: &str) -> bool {
177 quota_panel_selected(&self.quota.panels, provider)
178 }
179}
180
181#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
183pub struct QuotaConfig {
184 #[serde(default = "default_quota_panels")]
188 pub panels: Vec<String>,
189 #[serde(default = "default_quota_refresh_secs")]
192 pub refresh_interval: u64,
193}
194
195impl Default for QuotaConfig {
196 fn default() -> Self {
197 Self {
198 panels: default_quota_panels(),
199 refresh_interval: default_quota_refresh_secs(),
200 }
201 }
202}
203
204pub fn quota_panel_selected(panels: &[String], name: &str) -> bool {
209 panels.iter().any(|p| p.eq_ignore_ascii_case(name))
210}
211
212#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
214pub struct AnalysisConfig {
215 #[serde(default = "default_refresh_secs")]
217 pub refresh_interval: u64,
218}
219
220impl Default for AnalysisConfig {
221 fn default() -> Self {
222 Self {
223 refresh_interval: default_refresh_secs(),
224 }
225 }
226}
227
228impl AnalysisConfig {
229 pub fn refresh_secs(&self) -> u64 {
231 self.refresh_interval.max(1)
232 }
233}
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
240pub struct ProvidersConfig {
241 #[serde(default = "default_true")]
242 pub claude: bool,
243 #[serde(default = "default_true")]
244 pub codex: bool,
245 #[serde(default = "default_true")]
246 pub copilot: bool,
247 #[serde(default = "default_true")]
248 pub gemini: bool,
249 #[serde(default = "default_true")]
250 pub opencode: bool,
251 #[serde(default = "default_true")]
252 pub cursor: bool,
253 #[serde(default = "default_true")]
254 pub hermes: bool,
255 #[serde(default = "default_true")]
256 pub grok: bool,
257}
258
259impl Default for ProvidersConfig {
260 fn default() -> Self {
261 Self {
262 claude: true,
263 codex: true,
264 copilot: true,
265 gemini: true,
266 opencode: true,
267 cursor: true,
268 hermes: true,
269 grok: true,
270 }
271 }
272}
273
274#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
281pub struct LoggingConfig {
282 #[serde(default)]
285 pub level: LogLevel,
286 #[serde(default = "default_log_retention_days")]
289 pub retention_days: u32,
290}
291
292impl Default for LoggingConfig {
293 fn default() -> Self {
294 Self {
295 level: LogLevel::default(),
296 retention_days: default_log_retention_days(),
297 }
298 }
299}
300
301#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
303#[serde(rename_all = "lowercase")]
304pub enum LogLevel {
305 Off,
307 Error,
309 #[default]
311 Warn,
312 Info,
314 Debug,
316 Trace,
318}
319
320fn default_true() -> bool {
321 true
322}
323
324fn default_log_retention_days() -> u32 {
325 7
326}
327
328fn default_refresh_secs() -> u64 {
329 10
330}
331
332fn default_quota_refresh_secs() -> u64 {
333 60
334}
335
336fn legacy_config_version() -> u32 {
338 1
339}
340
341fn de_config_version<'de, D>(deserializer: D) -> Result<u32, D::Error>
349where
350 D: serde::Deserializer<'de>,
351{
352 Ok(Value::deserialize(deserializer)
353 .ok()
354 .and_then(|v| v.as_u64())
355 .and_then(|n| u32::try_from(n).ok())
356 .unwrap_or_else(legacy_config_version))
357}
358
359fn default_quota_panels() -> Vec<String> {
360 ["claude", "codex", "copilot", "cursor", "grok"]
361 .iter()
362 .map(|s| s.to_string())
363 .collect()
364}
365
366pub fn load() -> Config {
372 match get_cache_dir() {
373 Ok(dir) => load_in(&dir),
374 Err(_) => Config::default(),
375 }
376}
377
378pub fn load_in(dir: &Path) -> Config {
380 let path = dir.join("config.toml");
381 if path.exists() {
382 let Ok(text) = std::fs::read_to_string(&path) else {
383 return Config::default();
384 };
385 let effective = match migrate_text(&text) {
390 Ok(Some(migrated)) => {
391 let _ = write_string_atomic(&path, &migrated);
392 migrated
393 }
394 Ok(None) => text,
395 Err(_) => return Config::default(),
396 };
397 let mut config: Config = toml_edit::de::from_str(&effective).unwrap_or_default();
398 migrate_legacy(&mut config, &effective);
401 return config;
402 }
403 let text = default_document().to_string();
405 let _ = write_string_atomic(&path, &text);
406 toml_edit::de::from_str(&text).unwrap_or_default()
407}
408
409#[derive(Debug, Clone, Copy, PartialEq, Eq)]
412pub enum MigrationStatus {
413 Created,
415 Migrated,
417 AlreadyCurrent,
419}
420
421pub fn migrate_config_file(path: &Path) -> Result<MigrationStatus> {
425 if !path.exists() {
426 write_string_atomic(path, &default_document().to_string())?;
427 return Ok(MigrationStatus::Created);
428 }
429 let text = std::fs::read_to_string(path)?;
430 match migrate_text(&text)? {
431 Some(migrated) => {
432 write_string_atomic(path, &migrated)?;
433 Ok(MigrationStatus::Migrated)
434 }
435 None => Ok(MigrationStatus::AlreadyCurrent),
436 }
437}
438
439pub fn migrate_text(text: &str) -> Result<Option<String>> {
443 let mut doc: DocumentMut = text.parse()?;
444 Ok(migrate_document(&mut doc).then(|| doc.to_string()))
445}
446
447fn migrate_legacy(config: &mut Config, raw: &str) {
455 let Ok(doc) = raw.parse::<DocumentMut>() else {
456 return;
457 };
458 if let Some(usage) = doc.get("usage").and_then(Item::as_table_like) {
459 let has_new_panels = usage
462 .get("quota")
463 .and_then(Item::as_table_like)
464 .is_some_and(|q| q.contains_key("panels"));
465 if !has_new_panels && let Some(legacy) = usage.get("quota_panels").and_then(Item::as_array)
466 {
467 config.usage.quota.panels = legacy
468 .iter()
469 .filter_map(|v| v.as_str().map(str::to_string))
470 .collect();
471 }
472 migrate_refresh_secs(usage, &mut config.usage.refresh_interval);
473 }
474 if let Some(analysis) = doc.get("analysis").and_then(Item::as_table_like) {
475 migrate_refresh_secs(analysis, &mut config.analysis.refresh_interval);
476 }
477}
478
479fn migrate_refresh_secs(section: &dyn TableLike, target: &mut u64) {
484 if !section.contains_key("refresh_interval")
485 && let Some(secs) = section
486 .get("refresh_interval_secs")
487 .and_then(Item::as_integer)
488 && let Ok(v) = u64::try_from(secs)
489 {
490 *target = v;
491 }
492}
493
494fn migrate_document(doc: &mut DocumentMut) -> bool {
502 let schema = json_schema();
503 let mut changed = false;
504
505 if doc.get("usage").is_some_and(Item::is_table) {
510 if let Some(usage) = doc.get_mut("usage").and_then(Item::as_table_mut) {
511 changed |= rename_refresh_key(usage, &schema, &["usage", "refresh_interval"]);
512 }
513 changed |= migrate_quota_panels(doc, &schema);
514 }
515
516 if let Some(analysis) = doc.get_mut("analysis").and_then(Item::as_table_mut) {
518 changed |= rename_refresh_key(analysis, &schema, &["analysis", "refresh_interval"]);
519 }
520
521 changed |= backfill_new_quota_panels(doc, &schema);
524
525 if !has_schema_directive(doc) {
530 changed |= prepend_schema_directive(doc);
531 }
532
533 changed
534}
535
536fn backfill_new_quota_panels(doc: &mut DocumentMut, schema: &Value) -> bool {
548 let from_version = match doc.get("general") {
549 Some(item) => {
550 let Some(general) = item.as_table() else {
551 return false;
553 };
554 general
555 .get("version")
556 .and_then(Item::as_integer)
557 .and_then(|v| u32::try_from(v).ok())
558 .unwrap_or_else(legacy_config_version)
559 }
560 None => legacy_config_version(),
561 };
562 if from_version >= CONFIG_VERSION {
563 return false;
564 }
565
566 if panels_out_of_reach(doc) {
570 return false;
571 }
572
573 if let Some(panels) = doc
574 .get_mut("usage")
575 .and_then(Item::as_table_mut)
576 .and_then(|usage| usage.get_mut("quota"))
577 .and_then(Item::as_table_mut)
578 .and_then(|quota| quota.get_mut("panels"))
579 .and_then(Item::as_array_mut)
580 .filter(|panels| !panels.is_empty())
582 {
583 let missing: Vec<&str> = PANELS_ADDED_BY_VERSION
584 .iter()
585 .filter(|(version, _)| from_version < *version)
586 .map(|(_, name)| *name)
587 .filter(|name| {
588 !panels
589 .iter()
590 .filter_map(|p| p.as_str())
591 .any(|p| p.eq_ignore_ascii_case(name))
592 })
593 .collect();
594 for name in missing {
595 panels.push(name);
596 }
597 }
598
599 stamp_config_version(doc, schema);
600 true
601}
602
603fn panels_out_of_reach(doc: &DocumentMut) -> bool {
610 let Some(usage) = doc.get("usage") else {
611 return false;
612 };
613 let holds_panels = |table: &dyn TableLike| {
614 table.contains_key("quota_panels")
615 || table
616 .get("quota")
617 .and_then(Item::as_table_like)
618 .is_some_and(|quota| quota.contains_key("panels"))
619 };
620 match usage.as_table() {
621 None => usage.as_table_like().is_some_and(holds_panels),
623 Some(usage) => {
624 let inline_quota = usage
626 .get("quota")
627 .filter(|quota| !quota.is_table())
628 .and_then(Item::as_table_like)
629 .is_some_and(|quota| quota.contains_key("panels"));
630 inline_quota || usage.contains_key("quota_panels")
634 }
635 }
636}
637
638fn stamp_config_version(doc: &mut DocumentMut, schema: &Value) {
641 if doc.get("general").is_none() {
642 let mut table = Table::new();
643 table.set_implicit(false);
644 if let Some(desc) = schema_description(schema, &["general"]) {
645 table
646 .decor_mut()
647 .set_prefix(format!("\n{}", comment_block(&desc)));
648 }
649 doc.insert("general", Item::Table(table));
650 }
651 let Some(general) = doc.get_mut("general").and_then(Item::as_table_mut) else {
652 return;
653 };
654 let had_key = general.contains_key("version");
655 general.insert("version", value(i64::from(CONFIG_VERSION)));
656 if !had_key {
657 apply_comment(general, "version", "", schema, &["general", "version"]);
658 }
659}
660
661fn has_schema_directive(doc: &DocumentMut) -> bool {
663 doc.to_string()
667 .lines()
668 .find(|line| !line.trim().is_empty())
669 .is_some_and(|line| line.trim_start().starts_with("#:schema"))
670}
671
672fn prepend_schema_directive(doc: &mut DocumentMut) -> bool {
675 let directive = format!("#:schema {SCHEMA_URL}\n");
676 let root = doc.as_table_mut();
677 let Some(first_key) = root.iter().next().map(|(k, _)| k.to_string()) else {
678 return false;
679 };
680 if let Some(item) = root.get_mut(&first_key)
681 && let Some(table) = item.as_table_mut()
682 {
683 let existing = decor_prefix(table.decor());
684 table
685 .decor_mut()
686 .set_prefix(format!("{directive}{existing}"));
687 return true;
688 }
689 if let Some(mut km) = root.key_mut(&first_key) {
690 let existing = decor_prefix(km.leaf_decor());
691 km.leaf_decor_mut()
692 .set_prefix(format!("{directive}{existing}"));
693 return true;
694 }
695 false
696}
697
698fn decor_prefix(decor: &toml_edit::Decor) -> String {
700 decor
701 .prefix()
702 .and_then(|p| p.as_str())
703 .unwrap_or("")
704 .to_string()
705}
706
707fn rename_refresh_key(table: &mut Table, schema: &Value, comment_path: &[&str]) -> bool {
716 if !table.contains_key("refresh_interval_secs") {
717 return false;
718 }
719 if table.contains_key("refresh_interval") {
721 table.remove("refresh_interval_secs");
722 return true;
723 }
724 let Some((old_key, item)) = table.remove_entry("refresh_interval_secs") else {
725 return false;
726 };
727 if let Some(secs) = item.as_integer().and_then(|n| u64::try_from(n).ok()) {
728 table.insert("refresh_interval", value(secs as i64));
729 let existing = decor_prefix(old_key.leaf_decor());
731 apply_comment(table, "refresh_interval", &existing, schema, comment_path);
732 }
733 true
734}
735
736fn migrate_quota_panels(doc: &mut DocumentMut, schema: &Value) -> bool {
743 let Some(usage) = doc.get("usage").and_then(Item::as_table) else {
744 return false;
745 };
746 if !usage.contains_key("quota_panels") {
747 return false;
748 }
749 if usage.get("quota").is_some_and(Item::is_inline_table) {
750 return false;
751 }
752 let has_new_panels = usage
753 .get("quota")
754 .and_then(Item::as_table_like)
755 .is_some_and(|q| q.contains_key("panels"));
756
757 let usage = doc
758 .get_mut("usage")
759 .and_then(Item::as_table_mut)
760 .expect("usage table present");
761 let Some((old_key, old_item)) = usage.remove_entry("quota_panels") else {
762 return false;
763 };
764 if has_new_panels {
765 return true;
767 }
768 let legacy_panels: Vec<String> = old_item
769 .as_array()
770 .map(|a| {
771 a.iter()
772 .filter_map(|v| v.as_str().map(str::to_string))
773 .collect()
774 })
775 .unwrap_or_default();
776 let comment = decor_prefix(old_key.leaf_decor());
778
779 if usage.get("quota").and_then(Item::as_table).is_some() {
780 let quota = usage
782 .get_mut("quota")
783 .and_then(Item::as_table_mut)
784 .expect("quota table present");
785 quota.insert("panels", value(panels_array(&legacy_panels)));
786 apply_comment(
787 quota,
788 "panels",
789 &comment,
790 schema,
791 &["usage", "quota", "panels"],
792 );
793 } else {
794 usage.insert(
795 "quota",
796 Item::Table(build_quota_table(&legacy_panels, &comment, schema)),
797 );
798 }
799 true
800}
801
802fn panels_array(panels: &[String]) -> Array {
804 panels.iter().map(String::as_str).collect()
805}
806
807fn build_quota_table(panels: &[String], panels_comment: &str, schema: &Value) -> Table {
811 let mut table = Table::new();
812 table.set_implicit(false);
813 if let Some(desc) = schema_description(schema, &["usage", "quota"]) {
814 table
815 .decor_mut()
816 .set_prefix(format!("\n{}", comment_block(&desc)));
817 }
818 table.insert("panels", value(panels_array(panels)));
819 apply_comment(
820 &mut table,
821 "panels",
822 panels_comment,
823 schema,
824 &["usage", "quota", "panels"],
825 );
826 table.insert(
827 "refresh_interval",
828 value(default_quota_refresh_secs() as i64),
829 );
830 apply_comment(
831 &mut table,
832 "refresh_interval",
833 "",
834 schema,
835 &["usage", "quota", "refresh_interval"],
836 );
837 table
838}
839
840fn apply_comment(table: &mut Table, key: &str, existing: &str, schema: &Value, path: &[&str]) {
844 let prefix = if existing.trim().is_empty() {
845 schema_description(schema, path).map(|desc| format!("\n{}", comment_block(&desc)))
846 } else {
847 Some(existing.to_string())
848 };
849 if let Some(prefix) = prefix
850 && let Some(mut km) = table.key_mut(key)
851 {
852 km.leaf_decor_mut().set_prefix(prefix);
853 }
854}
855
856fn schema_description(schema: &Value, path: &[&str]) -> Option<String> {
858 let mut cur = schema;
859 for key in path {
860 cur = cur.get("properties")?.get(key)?;
861 }
862 cur.get("description")
863 .and_then(Value::as_str)
864 .map(str::to_string)
865}
866
867pub fn save_merge_models(enabled: bool) -> Result<()> {
869 save_merge_models_in(&get_cache_dir()?, enabled)
870}
871
872pub fn save_merge_models_in(dir: &Path, enabled: bool) -> Result<()> {
874 edit_in(dir, |doc| {
875 if !doc["usage"].is_table_like() {
882 doc["usage"] = Item::Table(Table::new());
883 }
884 doc["usage"]["merge_models"] = value(enabled);
885 })
886}
887
888fn edit_in(dir: &Path, mutate: impl FnOnce(&mut DocumentMut)) -> Result<()> {
891 let path = dir.join("config.toml");
892 let mut doc = std::fs::read_to_string(&path)
893 .ok()
894 .and_then(|text| text.parse::<DocumentMut>().ok())
895 .unwrap_or_else(default_document);
896 mutate(&mut doc);
897 write_string_atomic(&path, &doc.to_string())
898}
899
900pub fn json_schema() -> Value {
904 let settings =
905 schemars::generate::SchemaSettings::draft2020_12().with(|s| s.inline_subschemas = true);
906 let schema = settings.into_generator().into_root_schema_for::<Config>();
907 serde_json::to_value(&schema).expect("config schema serializes to JSON")
908}
909
910pub fn schema_json() -> String {
913 let mut s = serde_json::to_string_pretty(&json_schema()).expect("schema serializes");
914 s.push('\n');
915 s
916}
917
918fn default_document() -> DocumentMut {
923 let mut doc: DocumentMut = toml_edit::ser::to_string(&Config::default())
924 .expect("default config serializes to TOML")
925 .parse()
926 .expect("serialized default config is valid TOML");
927 let schema = json_schema();
928 annotate_table(doc.as_table_mut(), &schema, true);
929 doc
930}
931
932fn annotate_table(table: &mut Table, schema: &Value, is_root: bool) {
936 let props = schema
937 .get("properties")
938 .and_then(Value::as_object)
939 .cloned()
940 .unwrap_or_default();
941 let keys: Vec<String> = table.iter().map(|(k, _)| k.to_string()).collect();
942 for (idx, key) in keys.iter().enumerate() {
943 let field_schema = props.get(key);
944 let desc = field_schema
945 .and_then(|s| s.get("description"))
946 .and_then(Value::as_str);
947 let is_table = table
948 .get(key)
949 .is_some_and(|i| i.is_table() || i.is_inline_table());
950 if is_table {
951 let field_schema = field_schema.cloned();
952 if let Some(mut km) = table.key_mut(key) {
955 km.leaf_decor_mut().set_suffix("");
956 }
957 if let Some(item) = table.get_mut(key)
958 && let Some(sub) = as_standard_table(item)
959 {
960 let mut prefix = String::new();
961 if is_root && idx == 0 {
962 prefix.push_str(&format!("#:schema {SCHEMA_URL}\n\n"));
963 } else {
964 prefix.push('\n');
965 }
966 if let Some(d) = desc {
967 prefix.push_str(&comment_block(d));
968 }
969 sub.decor_mut().set_prefix(prefix);
970 if let Some(fs) = field_schema {
971 annotate_table(sub, &fs, false);
972 }
973 }
974 } else if let Some(d) = desc
975 && let Some(mut km) = table.key_mut(key)
976 {
977 km.leaf_decor_mut()
978 .set_prefix(format!("\n{}", comment_block(d)));
979 }
980 }
981}
982
983fn as_standard_table(item: &mut Item) -> Option<&mut Table> {
986 if item.is_inline_table()
987 && let Some(inline) = item.as_inline_table().cloned()
988 {
989 *item = Item::Table(inline.into_table());
990 }
991 item.as_table_mut()
992}
993
994fn comment_block(desc: &str) -> String {
997 desc.lines()
998 .map(|line| {
999 if line.is_empty() {
1000 "#\n".to_string()
1001 } else {
1002 format!("# {line}\n")
1003 }
1004 })
1005 .collect()
1006}
1007
1008#[cfg(test)]
1009mod tests {
1010 use super::*;
1011
1012 #[test]
1013 fn generated_template_parses_to_expected_defaults() {
1014 let text = default_document().to_string();
1015 let cfg: Config = toml_edit::de::from_str(&text).unwrap();
1016 assert_eq!(cfg, Config::default());
1017 assert!(text.starts_with(&format!("#:schema {SCHEMA_URL}")));
1018 assert_eq!(cfg.general.default_time_range, TimeRange::All);
1019 assert_eq!(cfg.usage.quota.panels, default_quota_panels());
1020 assert!(cfg.usage.shows_quota_panel("cursor"));
1021 assert!(!cfg.usage.merge_models);
1022 assert_eq!(cfg.usage.refresh_interval, 10);
1023 assert_eq!(cfg.usage.quota.refresh_interval, 60);
1024 assert_eq!(cfg.analysis.refresh_interval, 10);
1025 assert_eq!(cfg.performance.scan_threads, 0);
1026 assert_eq!(cfg.providers, ProvidersConfig::default());
1027 assert!(cfg.providers.cursor);
1028 assert!(cfg.providers.grok);
1029 assert_eq!(cfg.logging.level, LogLevel::Warn);
1030 assert_eq!(cfg.logging.retention_days, 7);
1031 }
1032
1033 #[test]
1034 fn missing_sections_use_defaults() {
1035 let cfg: Config = toml_edit::de::from_str("").unwrap();
1038 assert_eq!(cfg.usage.quota.panels, default_quota_panels());
1039 assert!(cfg.providers.cursor);
1040 assert!(cfg.providers.grok);
1041 assert_eq!(cfg.usage.refresh_secs(), 10);
1042 assert_eq!(cfg.usage.quota_refresh_secs(), 60);
1043 assert_eq!(cfg.logging, LoggingConfig::default());
1046 assert_eq!(cfg.performance, PerformanceConfig::default());
1047 }
1048
1049 #[test]
1050 fn scan_threads_resolve_config_then_env_then_auto() {
1051 let auto = PerformanceConfig { scan_threads: 0 };
1052 assert_eq!(auto.resolved_scan_threads_with(16, None), 2);
1053 assert_eq!(auto.resolved_scan_threads_with(1, None), 1);
1054 assert_eq!(auto.resolved_scan_threads_with(16, Some("7")), 7);
1055 assert_eq!(auto.resolved_scan_threads_with(4, Some("99")), 4);
1056 assert_eq!(auto.resolved_scan_threads_with(16, Some("0")), 2);
1057 assert_eq!(auto.resolved_scan_threads_with(16, Some("invalid")), 2);
1058
1059 let configured = PerformanceConfig { scan_threads: 12 };
1060 assert_eq!(configured.resolved_scan_threads_with(8, Some("3")), 8);
1061 }
1062
1063 #[test]
1064 fn partial_usage_section_keeps_quota_default() {
1065 let cfg: Config = toml_edit::de::from_str("[usage]\nmerge_models = true\n").unwrap();
1066 assert!(cfg.usage.merge_models);
1067 assert_eq!(cfg.usage.quota.panels, default_quota_panels());
1068 assert_eq!(cfg.usage.quota_refresh_secs(), 60);
1069 }
1070
1071 #[test]
1072 fn quota_panels_can_be_narrowed_or_emptied() {
1073 let cfg: Config =
1074 toml_edit::de::from_str("[usage.quota]\npanels = [\"claude\"]\n").unwrap();
1075 assert!(cfg.usage.shows_quota_panel("claude"));
1076 assert!(!cfg.usage.shows_quota_panel("cursor"));
1077
1078 let empty: Config = toml_edit::de::from_str("[usage.quota]\npanels = []\n").unwrap();
1079 assert!(!empty.usage.shows_quota_panel("claude"));
1080 }
1081
1082 #[test]
1083 fn refresh_interval_reads_legacy_secs() {
1084 let text = "[usage]\nrefresh_interval_secs = 5\n[analysis]\nrefresh_interval_secs = 7\n";
1086 let mut cfg: Config = toml_edit::de::from_str(text).unwrap();
1087 migrate_legacy(&mut cfg, text);
1088 assert_eq!(cfg.usage.refresh_secs(), 5);
1089 assert_eq!(cfg.analysis.refresh_secs(), 7);
1090 }
1091
1092 #[test]
1093 fn coexisting_refresh_keys_preserve_the_rest_of_the_config() {
1094 let text = "[general]\ndefault_time_range = \"weekly\"\n[usage]\nrefresh_interval = 5\nrefresh_interval_secs = 10\n[providers]\ncursor = false\n";
1099 let mut cfg: Config = toml_edit::de::from_str(text).unwrap();
1100 migrate_legacy(&mut cfg, text);
1101 assert_eq!(cfg.usage.refresh_secs(), 5);
1102 assert_eq!(cfg.general.default_time_range, TimeRange::Weekly);
1103 assert!(!cfg.providers.cursor);
1104 }
1105
1106 #[test]
1107 fn migrates_legacy_quota_panels() {
1108 let text = "[usage]\nquota_panels = [\"claude\"]\n";
1110 let mut cfg: Config = toml_edit::de::from_str(text).unwrap();
1111 migrate_legacy(&mut cfg, text);
1112 assert_eq!(cfg.usage.quota.panels, vec!["claude".to_string()]);
1113 assert!(!cfg.usage.shows_quota_panel("cursor"));
1114 }
1115
1116 #[test]
1117 fn new_quota_panels_win_over_legacy() {
1118 let text = "[usage]\nquota_panels = [\"claude\"]\n[usage.quota]\npanels = []\n";
1119 let mut cfg: Config = toml_edit::de::from_str(text).unwrap();
1120 migrate_legacy(&mut cfg, text);
1121 assert!(cfg.usage.quota.panels.is_empty());
1122 }
1123
1124 #[test]
1125 fn migrate_document_upgrades_a_full_legacy_file() {
1126 let legacy = "# old header\n\n[usage]\nmerge_models = false\nquota_panels = [\"claude\", \"codex\"]\nrefresh_interval_secs = 15\n\n[analysis]\nrefresh_interval_secs = 20\n";
1127 let migrated = migrate_text(legacy).unwrap().expect("legacy file changes");
1128 assert!(migrated.starts_with("#:schema "));
1130 assert!(!migrated.contains("quota_panels"));
1132 assert!(!migrated.contains("refresh_interval_secs"));
1133 assert!(migrated.contains("[usage.quota]"));
1134 assert!(migrated.contains("panels = [\"claude\", \"codex\", \"grok\"]"));
1137 let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
1138 assert_eq!(cfg.usage.refresh_interval, 15);
1139 assert_eq!(cfg.analysis.refresh_interval, 20);
1140 assert_eq!(
1141 cfg.usage.quota.panels,
1142 vec![
1143 "claude".to_string(),
1144 "codex".to_string(),
1145 "grok".to_string()
1146 ]
1147 );
1148 assert_eq!(cfg.usage.quota.refresh_interval, 60);
1149 assert_eq!(cfg.general.version, CONFIG_VERSION);
1150 }
1151
1152 #[test]
1153 fn migrate_document_is_idempotent() {
1154 let legacy = "[usage]\nquota_panels = [\"claude\"]\nrefresh_interval_secs = 15\n";
1155 let once = migrate_text(legacy).unwrap().expect("first pass changes");
1156 assert!(migrate_text(&once).unwrap().is_none());
1158 }
1159
1160 #[test]
1161 fn migrate_document_noops_on_the_generated_default() {
1162 let current = default_document().to_string();
1163 assert!(migrate_text(¤t).unwrap().is_none());
1164 }
1165
1166 #[test]
1167 fn migrate_document_adds_only_the_schema_directive_when_keys_are_current() {
1168 let text = "[general]\nversion = 2\n\n[usage]\nrefresh_interval = 10\n\n[usage.quota]\npanels = [\"claude\"]\n";
1171 let migrated = migrate_text(text).unwrap().expect("adds #:schema");
1172 assert!(migrated.starts_with("#:schema "));
1173 assert!(migrated.contains("refresh_interval = 10"));
1174 assert!(migrated.contains("panels = [\"claude\"]"));
1175 }
1176
1177 #[test]
1178 fn migrate_document_drops_stale_refresh_key_when_both_present() {
1179 let text = "#:schema x\n[usage]\nrefresh_interval = 5\nrefresh_interval_secs = 10\n";
1180 let migrated = migrate_text(text).unwrap().expect("drops the legacy key");
1181 assert!(!migrated.contains("refresh_interval_secs"));
1182 let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
1183 assert_eq!(cfg.usage.refresh_interval, 5);
1184 }
1185
1186 #[test]
1187 fn migrate_document_leaves_an_inline_usage_table_to_the_read_shim() {
1188 let text = "usage = { quota_panels = [\"claude\"], refresh_interval_secs = 30 }\n";
1191 let migrated = migrate_text(text).unwrap().expect("adds #:schema");
1192 assert!(migrated.starts_with("#:schema "));
1193 assert!(migrated.contains("quota_panels"));
1194 assert!(migrate_text(&migrated).unwrap().is_none());
1196 }
1197
1198 #[test]
1199 fn migrate_text_errors_on_malformed_toml() {
1200 assert!(migrate_text("= not valid").is_err());
1201 }
1202
1203 #[test]
1204 fn migrate_document_drops_a_malformed_legacy_refresh_value() {
1205 let text = "[general]\ndefault_time_range = \"weekly\"\n[usage]\nrefresh_interval_secs = -5\n[providers]\ncursor = false\n";
1209 let migrated = migrate_text(text).unwrap().expect("legacy file changes");
1210 assert!(!migrated.contains("refresh_interval = -5"));
1211 assert!(!migrated.contains("refresh_interval_secs"));
1212 let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
1213 assert_eq!(cfg.general.default_time_range, TimeRange::Weekly);
1214 assert!(!cfg.providers.cursor);
1215 assert_eq!(cfg.usage.refresh_interval, 10);
1216 assert!(migrate_text(&migrated).unwrap().is_none());
1218 }
1219
1220 #[test]
1221 fn migrate_document_preserves_a_hand_added_comment_on_a_renamed_key() {
1222 let text = "[usage]\n# keep me\nrefresh_interval_secs = 15\n";
1223 let migrated = migrate_text(text).unwrap().expect("legacy file changes");
1224 assert!(migrated.contains("# keep me"));
1225 assert!(migrated.contains("refresh_interval = 15"));
1226 }
1227
1228 #[test]
1229 fn migrate_document_adds_the_directive_despite_a_buried_schema_line() {
1230 let text = "[usage]\nquota_panels = [\"claude\"]\nnote = \"\"\"\n#:schema fake\n\"\"\"\n";
1233 let migrated = migrate_text(text)
1234 .unwrap()
1235 .expect("adds the real directive");
1236 assert!(migrated.starts_with("#:schema https://"));
1237 }
1238
1239 #[test]
1240 fn migrate_document_merges_panels_into_an_existing_quota_table() {
1241 let text = "[usage]\nquota_panels = [\"claude\"]\n\n[usage.quota]\nrefresh_interval = 30\n";
1244 let migrated = migrate_text(text).unwrap().expect("legacy file changes");
1245 assert!(!migrated.contains("quota_panels"));
1246 let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
1247 assert_eq!(
1248 cfg.usage.quota.panels,
1249 vec!["claude".to_string(), "grok".to_string()]
1250 );
1251 assert_eq!(cfg.usage.quota.refresh_interval, 30);
1252 assert!(migrate_text(&migrated).unwrap().is_none());
1253 }
1254
1255 #[test]
1256 fn migrate_document_drops_legacy_quota_panels_when_new_panels_present() {
1257 let text = "#:schema x\n[usage]\nquota_panels = [\"gemini\"]\n\n[usage.quota]\npanels = [\"claude\"]\n";
1258 let migrated = migrate_text(text).unwrap().expect("drops the legacy key");
1259 assert!(!migrated.contains("quota_panels"));
1260 let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
1261 assert_eq!(
1262 cfg.usage.quota.panels,
1263 vec!["claude".to_string(), "grok".to_string()]
1264 );
1265 }
1266
1267 #[test]
1268 fn migrate_document_skips_an_inline_quota_child() {
1269 let text =
1273 "#:schema x\n[usage]\nquota_panels = [\"claude\"]\nquota = { panels = [\"codex\"] }\n";
1274 assert!(migrate_text(text).unwrap().is_none());
1275 assert!(panels_out_of_reach(&text.parse::<DocumentMut>().unwrap()));
1276 }
1277
1278 #[test]
1281 fn backfilled_panel_is_added_once_and_stays_removed() {
1282 let text = "#:schema x\n[general]\ndefault_time_range = \"all\"\n\n[usage.quota]\npanels = [\"claude\", \"codex\"]\n";
1283 let migrated = migrate_text(text)
1284 .unwrap()
1285 .expect("version 1 file upgrades");
1286 assert!(migrated.contains("panels = [\"claude\", \"codex\", \"grok\"]"));
1287 assert!(migrated.contains("version = 2"));
1288 assert!(migrate_text(&migrated).unwrap().is_none());
1289
1290 let pruned = migrated.replace(
1292 "[\"claude\", \"codex\", \"grok\"]",
1293 "[\"claude\", \"codex\"]",
1294 );
1295 assert!(migrate_text(&pruned).unwrap().is_none());
1296 let cfg: Config = toml_edit::de::from_str(&pruned).unwrap();
1297 assert!(!cfg.usage.shows_quota_panel("grok"));
1298 }
1299
1300 #[test]
1303 fn backfill_leaves_previously_removed_panels_alone() {
1304 let text = "#:schema x\n[usage.quota]\npanels = [\"claude\"]\n";
1305 let migrated = migrate_text(text)
1306 .unwrap()
1307 .expect("version 1 file upgrades");
1308 let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
1309 assert_eq!(
1310 cfg.usage.quota.panels,
1311 vec!["claude".to_string(), "grok".to_string()],
1312 "codex / copilot / cursor were removed on purpose and stay removed"
1313 );
1314 }
1315
1316 #[test]
1317 fn backfill_never_reopens_a_band_turned_off() {
1318 let text = "#:schema x\n[usage.quota]\npanels = []\n";
1320 let migrated = migrate_text(text).unwrap().expect("the marker is stamped");
1321 let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
1322 assert!(cfg.usage.quota.panels.is_empty());
1323 assert_eq!(cfg.general.version, CONFIG_VERSION);
1324 }
1325
1326 #[test]
1327 fn backfill_stamps_a_file_with_no_general_section() {
1328 let text = "#:schema x\n[providers]\ncursor = false\n";
1331 let migrated = migrate_text(text).unwrap().expect("the marker is stamped");
1332 let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
1333 assert_eq!(cfg.general.version, CONFIG_VERSION);
1334 assert!(!cfg.providers.cursor);
1335 assert!(cfg.usage.shows_quota_panel("grok"));
1337 assert!(migrate_text(&migrated).unwrap().is_none());
1338 }
1339
1340 #[test]
1341 fn an_empty_file_reaches_the_current_layout_in_one_pass() {
1342 for text in ["", "# just a comment\n"] {
1346 let migrated = migrate_text(text)
1347 .unwrap()
1348 .expect("an empty file is stamped");
1349 assert!(migrated.starts_with("#:schema "));
1350 assert!(migrated.contains("version = 2"));
1351 assert!(
1352 migrate_text(&migrated).unwrap().is_none(),
1353 "one pass must be enough"
1354 );
1355 }
1356 }
1357
1358 #[test]
1359 fn a_panel_list_that_could_not_be_moved_is_not_marked_upgraded() {
1360 let text =
1364 "#:schema x\n[usage]\nquota_panels = [\"claude\"]\nquota = { refresh_interval = 30 }\n";
1365 assert!(migrate_text(text).unwrap().is_none());
1366 assert!(panels_out_of_reach(&text.parse::<DocumentMut>().unwrap()));
1367 }
1368
1369 #[test]
1370 fn a_hand_edited_version_value_never_resets_the_config() {
1371 for bad in ["\"2\"", "2.5", "true", "-1"] {
1374 let text = format!(
1375 "[general]\nversion = {bad}\ndefault_time_range = \"weekly\"\n[providers]\ncursor = false\n"
1376 );
1377 let cfg: Config = toml_edit::de::from_str(&text).expect("document still parses");
1378 assert_eq!(cfg.general.version, legacy_config_version(), "bad = {bad}");
1379 assert_eq!(cfg.general.default_time_range, TimeRange::Weekly);
1380 assert!(!cfg.providers.cursor);
1381 let migrated = migrate_text(&text)
1383 .unwrap()
1384 .expect("the marker is repaired");
1385 let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
1386 assert_eq!(cfg.general.version, CONFIG_VERSION);
1387 }
1388 }
1389
1390 #[test]
1391 fn every_backfilled_panel_ships_by_the_current_version() {
1392 for (version, name) in PANELS_ADDED_BY_VERSION {
1393 assert!(
1394 *version <= CONFIG_VERSION,
1395 "{name} is scheduled for a version this build never stamps"
1396 );
1397 assert!(
1398 default_quota_panels().iter().any(|p| p == name),
1399 "{name} is back-filled but is not a current default panel"
1400 );
1401 }
1402 }
1403
1404 #[test]
1405 fn refresh_secs_clamps_zero_to_one() {
1406 let cfg = UsageConfig {
1407 refresh_interval: 0,
1408 quota: QuotaConfig {
1409 refresh_interval: 0,
1410 ..QuotaConfig::default()
1411 },
1412 ..UsageConfig::default()
1413 };
1414 assert_eq!(cfg.refresh_secs(), 1);
1415 assert_eq!(cfg.quota_refresh_secs(), 1);
1416 }
1417
1418 #[test]
1419 fn providers_default_all_enabled() {
1420 let p = ProvidersConfig::default();
1421 assert!(
1422 p.claude
1423 && p.codex
1424 && p.copilot
1425 && p.gemini
1426 && p.opencode
1427 && p.cursor
1428 && p.hermes
1429 && p.grok
1430 );
1431 }
1432
1433 #[test]
1434 fn committed_schema_matches_generated() {
1435 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../vct.schema.json");
1438 let committed: Value =
1439 serde_json::from_str(&std::fs::read_to_string(path).expect("vct.schema.json exists"))
1440 .expect("vct.schema.json is valid JSON");
1441 assert_eq!(committed, json_schema());
1442 }
1443}