use crate::models::TimeRange;
use crate::utils::{get_cache_dir, write_string_atomic};
use anyhow::Result;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::Path;
use toml_edit::{Array, DocumentMut, Item, Table, TableLike, value};
const SCHEMA_URL: &str =
"https://raw.githubusercontent.com/Mai0313/VibeCodingTracker/main/vct.schema.json";
const CONFIG_VERSION: u32 = 2;
const PANELS_ADDED_BY_VERSION: &[(u32, &str)] = &[(2, "grok")];
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
pub struct Config {
#[serde(default)]
pub general: GeneralConfig,
#[serde(default)]
pub usage: UsageConfig,
#[serde(default)]
pub analysis: AnalysisConfig,
#[serde(default)]
pub performance: PerformanceConfig,
#[serde(default)]
pub providers: ProvidersConfig,
#[serde(default)]
pub logging: LoggingConfig,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
pub struct PerformanceConfig {
#[serde(default)]
pub scan_threads: usize,
}
impl PerformanceConfig {
pub fn resolved_scan_threads(&self) -> usize {
let available = std::thread::available_parallelism()
.map(std::num::NonZeroUsize::get)
.unwrap_or(1);
self.resolved_scan_threads_with(
available,
std::env::var("RAYON_NUM_THREADS").ok().as_deref(),
)
}
fn resolved_scan_threads_with(&self, available: usize, rayon_env: Option<&str>) -> usize {
let available = available.max(1);
let requested = if self.scan_threads > 0 {
self.scan_threads
} else {
rayon_env
.and_then(|value| value.parse::<usize>().ok())
.filter(|value| *value > 0)
.unwrap_or_else(|| available.min(2))
};
requested.clamp(1, available)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct GeneralConfig {
#[serde(default)]
pub default_time_range: TimeRange,
#[serde(
default = "legacy_config_version",
deserialize_with = "de_config_version"
)]
pub version: u32,
}
impl Default for GeneralConfig {
fn default() -> Self {
Self {
default_time_range: TimeRange::default(),
version: CONFIG_VERSION,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct UsageConfig {
#[serde(default)]
pub merge_models: bool,
#[serde(default = "default_refresh_secs")]
pub refresh_interval: u64,
#[serde(default)]
pub quota: QuotaConfig,
}
impl Default for UsageConfig {
fn default() -> Self {
Self {
merge_models: false,
refresh_interval: default_refresh_secs(),
quota: QuotaConfig::default(),
}
}
}
impl UsageConfig {
pub fn refresh_secs(&self) -> u64 {
self.refresh_interval.max(1)
}
pub fn quota_refresh_secs(&self) -> u64 {
self.quota.refresh_interval.max(1)
}
pub fn shows_quota_panel(&self, provider: &str) -> bool {
quota_panel_selected(&self.quota.panels, provider)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct QuotaConfig {
#[serde(default = "default_quota_panels")]
pub panels: Vec<String>,
#[serde(default = "default_quota_refresh_secs")]
pub refresh_interval: u64,
}
impl Default for QuotaConfig {
fn default() -> Self {
Self {
panels: default_quota_panels(),
refresh_interval: default_quota_refresh_secs(),
}
}
}
pub fn quota_panel_selected(panels: &[String], name: &str) -> bool {
panels.iter().any(|p| p.eq_ignore_ascii_case(name))
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct AnalysisConfig {
#[serde(default = "default_refresh_secs")]
pub refresh_interval: u64,
}
impl Default for AnalysisConfig {
fn default() -> Self {
Self {
refresh_interval: default_refresh_secs(),
}
}
}
impl AnalysisConfig {
pub fn refresh_secs(&self) -> u64 {
self.refresh_interval.max(1)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct ProvidersConfig {
#[serde(default = "default_true")]
pub claude: bool,
#[serde(default = "default_true")]
pub codex: bool,
#[serde(default = "default_true")]
pub copilot: bool,
#[serde(default = "default_true")]
pub gemini: bool,
#[serde(default = "default_true")]
pub opencode: bool,
#[serde(default = "default_true")]
pub cursor: bool,
#[serde(default = "default_true")]
pub hermes: bool,
#[serde(default = "default_true")]
pub grok: bool,
}
impl Default for ProvidersConfig {
fn default() -> Self {
Self {
claude: true,
codex: true,
copilot: true,
gemini: true,
opencode: true,
cursor: true,
hermes: true,
grok: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct LoggingConfig {
#[serde(default)]
pub level: LogLevel,
#[serde(default = "default_log_retention_days")]
pub retention_days: u32,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
level: LogLevel::default(),
retention_days: default_log_retention_days(),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum LogLevel {
Off,
Error,
#[default]
Warn,
Info,
Debug,
Trace,
}
fn default_true() -> bool {
true
}
fn default_log_retention_days() -> u32 {
7
}
fn default_refresh_secs() -> u64 {
10
}
fn default_quota_refresh_secs() -> u64 {
60
}
fn legacy_config_version() -> u32 {
1
}
fn de_config_version<'de, D>(deserializer: D) -> Result<u32, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Value::deserialize(deserializer)
.ok()
.and_then(|v| v.as_u64())
.and_then(|n| u32::try_from(n).ok())
.unwrap_or_else(legacy_config_version))
}
fn default_quota_panels() -> Vec<String> {
["claude", "codex", "copilot", "cursor", "grok"]
.iter()
.map(|s| s.to_string())
.collect()
}
pub fn load() -> Config {
match get_cache_dir() {
Ok(dir) => load_in(&dir),
Err(_) => Config::default(),
}
}
pub fn load_in(dir: &Path) -> Config {
let path = dir.join("config.toml");
if path.exists() {
let Ok(text) = std::fs::read_to_string(&path) else {
return Config::default();
};
let effective = match migrate_text(&text) {
Ok(Some(migrated)) => {
let _ = write_string_atomic(&path, &migrated);
migrated
}
Ok(None) => text,
Err(_) => return Config::default(),
};
let mut config: Config = toml_edit::de::from_str(&effective).unwrap_or_default();
migrate_legacy(&mut config, &effective);
return config;
}
let text = default_document().to_string();
let _ = write_string_atomic(&path, &text);
toml_edit::de::from_str(&text).unwrap_or_default()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MigrationStatus {
Created,
Migrated,
AlreadyCurrent,
}
pub fn migrate_config_file(path: &Path) -> Result<MigrationStatus> {
if !path.exists() {
write_string_atomic(path, &default_document().to_string())?;
return Ok(MigrationStatus::Created);
}
let text = std::fs::read_to_string(path)?;
match migrate_text(&text)? {
Some(migrated) => {
write_string_atomic(path, &migrated)?;
Ok(MigrationStatus::Migrated)
}
None => Ok(MigrationStatus::AlreadyCurrent),
}
}
pub fn migrate_text(text: &str) -> Result<Option<String>> {
let mut doc: DocumentMut = text.parse()?;
Ok(migrate_document(&mut doc).then(|| doc.to_string()))
}
fn migrate_legacy(config: &mut Config, raw: &str) {
let Ok(doc) = raw.parse::<DocumentMut>() else {
return;
};
if let Some(usage) = doc.get("usage").and_then(Item::as_table_like) {
let has_new_panels = usage
.get("quota")
.and_then(Item::as_table_like)
.is_some_and(|q| q.contains_key("panels"));
if !has_new_panels && let Some(legacy) = usage.get("quota_panels").and_then(Item::as_array)
{
config.usage.quota.panels = legacy
.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect();
}
migrate_refresh_secs(usage, &mut config.usage.refresh_interval);
}
if let Some(analysis) = doc.get("analysis").and_then(Item::as_table_like) {
migrate_refresh_secs(analysis, &mut config.analysis.refresh_interval);
}
}
fn migrate_refresh_secs(section: &dyn TableLike, target: &mut u64) {
if !section.contains_key("refresh_interval")
&& let Some(secs) = section
.get("refresh_interval_secs")
.and_then(Item::as_integer)
&& let Ok(v) = u64::try_from(secs)
{
*target = v;
}
}
fn migrate_document(doc: &mut DocumentMut) -> bool {
let schema = json_schema();
let mut changed = false;
if doc.get("usage").is_some_and(Item::is_table) {
if let Some(usage) = doc.get_mut("usage").and_then(Item::as_table_mut) {
changed |= rename_refresh_key(usage, &schema, &["usage", "refresh_interval"]);
}
changed |= migrate_quota_panels(doc, &schema);
}
if let Some(analysis) = doc.get_mut("analysis").and_then(Item::as_table_mut) {
changed |= rename_refresh_key(analysis, &schema, &["analysis", "refresh_interval"]);
}
changed |= backfill_new_quota_panels(doc, &schema);
if !has_schema_directive(doc) {
changed |= prepend_schema_directive(doc);
}
changed
}
fn backfill_new_quota_panels(doc: &mut DocumentMut, schema: &Value) -> bool {
let from_version = match doc.get("general") {
Some(item) => {
let Some(general) = item.as_table() else {
return false;
};
general
.get("version")
.and_then(Item::as_integer)
.and_then(|v| u32::try_from(v).ok())
.unwrap_or_else(legacy_config_version)
}
None => legacy_config_version(),
};
if from_version >= CONFIG_VERSION {
return false;
}
if panels_out_of_reach(doc) {
return false;
}
if let Some(panels) = doc
.get_mut("usage")
.and_then(Item::as_table_mut)
.and_then(|usage| usage.get_mut("quota"))
.and_then(Item::as_table_mut)
.and_then(|quota| quota.get_mut("panels"))
.and_then(Item::as_array_mut)
.filter(|panels| !panels.is_empty())
{
let missing: Vec<&str> = PANELS_ADDED_BY_VERSION
.iter()
.filter(|(version, _)| from_version < *version)
.map(|(_, name)| *name)
.filter(|name| {
!panels
.iter()
.filter_map(|p| p.as_str())
.any(|p| p.eq_ignore_ascii_case(name))
})
.collect();
for name in missing {
panels.push(name);
}
}
stamp_config_version(doc, schema);
true
}
fn panels_out_of_reach(doc: &DocumentMut) -> bool {
let Some(usage) = doc.get("usage") else {
return false;
};
let holds_panels = |table: &dyn TableLike| {
table.contains_key("quota_panels")
|| table
.get("quota")
.and_then(Item::as_table_like)
.is_some_and(|quota| quota.contains_key("panels"))
};
match usage.as_table() {
None => usage.as_table_like().is_some_and(holds_panels),
Some(usage) => {
let inline_quota = usage
.get("quota")
.filter(|quota| !quota.is_table())
.and_then(Item::as_table_like)
.is_some_and(|quota| quota.contains_key("panels"));
inline_quota || usage.contains_key("quota_panels")
}
}
}
fn stamp_config_version(doc: &mut DocumentMut, schema: &Value) {
if doc.get("general").is_none() {
let mut table = Table::new();
table.set_implicit(false);
if let Some(desc) = schema_description(schema, &["general"]) {
table
.decor_mut()
.set_prefix(format!("\n{}", comment_block(&desc)));
}
doc.insert("general", Item::Table(table));
}
let Some(general) = doc.get_mut("general").and_then(Item::as_table_mut) else {
return;
};
let had_key = general.contains_key("version");
general.insert("version", value(i64::from(CONFIG_VERSION)));
if !had_key {
apply_comment(general, "version", "", schema, &["general", "version"]);
}
}
fn has_schema_directive(doc: &DocumentMut) -> bool {
doc.to_string()
.lines()
.find(|line| !line.trim().is_empty())
.is_some_and(|line| line.trim_start().starts_with("#:schema"))
}
fn prepend_schema_directive(doc: &mut DocumentMut) -> bool {
let directive = format!("#:schema {SCHEMA_URL}\n");
let root = doc.as_table_mut();
let Some(first_key) = root.iter().next().map(|(k, _)| k.to_string()) else {
return false;
};
if let Some(item) = root.get_mut(&first_key)
&& let Some(table) = item.as_table_mut()
{
let existing = decor_prefix(table.decor());
table
.decor_mut()
.set_prefix(format!("{directive}{existing}"));
return true;
}
if let Some(mut km) = root.key_mut(&first_key) {
let existing = decor_prefix(km.leaf_decor());
km.leaf_decor_mut()
.set_prefix(format!("{directive}{existing}"));
return true;
}
false
}
fn decor_prefix(decor: &toml_edit::Decor) -> String {
decor
.prefix()
.and_then(|p| p.as_str())
.unwrap_or("")
.to_string()
}
fn rename_refresh_key(table: &mut Table, schema: &Value, comment_path: &[&str]) -> bool {
if !table.contains_key("refresh_interval_secs") {
return false;
}
if table.contains_key("refresh_interval") {
table.remove("refresh_interval_secs");
return true;
}
let Some((old_key, item)) = table.remove_entry("refresh_interval_secs") else {
return false;
};
if let Some(secs) = item.as_integer().and_then(|n| u64::try_from(n).ok()) {
table.insert("refresh_interval", value(secs as i64));
let existing = decor_prefix(old_key.leaf_decor());
apply_comment(table, "refresh_interval", &existing, schema, comment_path);
}
true
}
fn migrate_quota_panels(doc: &mut DocumentMut, schema: &Value) -> bool {
let Some(usage) = doc.get("usage").and_then(Item::as_table) else {
return false;
};
if !usage.contains_key("quota_panels") {
return false;
}
if usage.get("quota").is_some_and(Item::is_inline_table) {
return false;
}
let has_new_panels = usage
.get("quota")
.and_then(Item::as_table_like)
.is_some_and(|q| q.contains_key("panels"));
let usage = doc
.get_mut("usage")
.and_then(Item::as_table_mut)
.expect("usage table present");
let Some((old_key, old_item)) = usage.remove_entry("quota_panels") else {
return false;
};
if has_new_panels {
return true;
}
let legacy_panels: Vec<String> = old_item
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let comment = decor_prefix(old_key.leaf_decor());
if usage.get("quota").and_then(Item::as_table).is_some() {
let quota = usage
.get_mut("quota")
.and_then(Item::as_table_mut)
.expect("quota table present");
quota.insert("panels", value(panels_array(&legacy_panels)));
apply_comment(
quota,
"panels",
&comment,
schema,
&["usage", "quota", "panels"],
);
} else {
usage.insert(
"quota",
Item::Table(build_quota_table(&legacy_panels, &comment, schema)),
);
}
true
}
fn panels_array(panels: &[String]) -> Array {
panels.iter().map(String::as_str).collect()
}
fn build_quota_table(panels: &[String], panels_comment: &str, schema: &Value) -> Table {
let mut table = Table::new();
table.set_implicit(false);
if let Some(desc) = schema_description(schema, &["usage", "quota"]) {
table
.decor_mut()
.set_prefix(format!("\n{}", comment_block(&desc)));
}
table.insert("panels", value(panels_array(panels)));
apply_comment(
&mut table,
"panels",
panels_comment,
schema,
&["usage", "quota", "panels"],
);
table.insert(
"refresh_interval",
value(default_quota_refresh_secs() as i64),
);
apply_comment(
&mut table,
"refresh_interval",
"",
schema,
&["usage", "quota", "refresh_interval"],
);
table
}
fn apply_comment(table: &mut Table, key: &str, existing: &str, schema: &Value, path: &[&str]) {
let prefix = if existing.trim().is_empty() {
schema_description(schema, path).map(|desc| format!("\n{}", comment_block(&desc)))
} else {
Some(existing.to_string())
};
if let Some(prefix) = prefix
&& let Some(mut km) = table.key_mut(key)
{
km.leaf_decor_mut().set_prefix(prefix);
}
}
fn schema_description(schema: &Value, path: &[&str]) -> Option<String> {
let mut cur = schema;
for key in path {
cur = cur.get("properties")?.get(key)?;
}
cur.get("description")
.and_then(Value::as_str)
.map(str::to_string)
}
pub fn save_merge_models(enabled: bool) -> Result<()> {
save_merge_models_in(&get_cache_dir()?, enabled)
}
pub fn save_merge_models_in(dir: &Path, enabled: bool) -> Result<()> {
edit_in(dir, |doc| {
if !doc["usage"].is_table_like() {
doc["usage"] = Item::Table(Table::new());
}
doc["usage"]["merge_models"] = value(enabled);
})
}
fn edit_in(dir: &Path, mutate: impl FnOnce(&mut DocumentMut)) -> Result<()> {
let path = dir.join("config.toml");
let mut doc = std::fs::read_to_string(&path)
.ok()
.and_then(|text| text.parse::<DocumentMut>().ok())
.unwrap_or_else(default_document);
mutate(&mut doc);
write_string_atomic(&path, &doc.to_string())
}
pub fn json_schema() -> Value {
let settings =
schemars::generate::SchemaSettings::draft2020_12().with(|s| s.inline_subschemas = true);
let schema = settings.into_generator().into_root_schema_for::<Config>();
serde_json::to_value(&schema).expect("config schema serializes to JSON")
}
pub fn schema_json() -> String {
let mut s = serde_json::to_string_pretty(&json_schema()).expect("schema serializes");
s.push('\n');
s
}
fn default_document() -> DocumentMut {
let mut doc: DocumentMut = toml_edit::ser::to_string(&Config::default())
.expect("default config serializes to TOML")
.parse()
.expect("serialized default config is valid TOML");
let schema = json_schema();
annotate_table(doc.as_table_mut(), &schema, true);
doc
}
fn annotate_table(table: &mut Table, schema: &Value, is_root: bool) {
let props = schema
.get("properties")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let keys: Vec<String> = table.iter().map(|(k, _)| k.to_string()).collect();
for (idx, key) in keys.iter().enumerate() {
let field_schema = props.get(key);
let desc = field_schema
.and_then(|s| s.get("description"))
.and_then(Value::as_str);
let is_table = table
.get(key)
.is_some_and(|i| i.is_table() || i.is_inline_table());
if is_table {
let field_schema = field_schema.cloned();
if let Some(mut km) = table.key_mut(key) {
km.leaf_decor_mut().set_suffix("");
}
if let Some(item) = table.get_mut(key)
&& let Some(sub) = as_standard_table(item)
{
let mut prefix = String::new();
if is_root && idx == 0 {
prefix.push_str(&format!("#:schema {SCHEMA_URL}\n\n"));
} else {
prefix.push('\n');
}
if let Some(d) = desc {
prefix.push_str(&comment_block(d));
}
sub.decor_mut().set_prefix(prefix);
if let Some(fs) = field_schema {
annotate_table(sub, &fs, false);
}
}
} else if let Some(d) = desc
&& let Some(mut km) = table.key_mut(key)
{
km.leaf_decor_mut()
.set_prefix(format!("\n{}", comment_block(d)));
}
}
}
fn as_standard_table(item: &mut Item) -> Option<&mut Table> {
if item.is_inline_table()
&& let Some(inline) = item.as_inline_table().cloned()
{
*item = Item::Table(inline.into_table());
}
item.as_table_mut()
}
fn comment_block(desc: &str) -> String {
desc.lines()
.map(|line| {
if line.is_empty() {
"#\n".to_string()
} else {
format!("# {line}\n")
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generated_template_parses_to_expected_defaults() {
let text = default_document().to_string();
let cfg: Config = toml_edit::de::from_str(&text).unwrap();
assert_eq!(cfg, Config::default());
assert!(text.starts_with(&format!("#:schema {SCHEMA_URL}")));
assert_eq!(cfg.general.default_time_range, TimeRange::All);
assert_eq!(cfg.usage.quota.panels, default_quota_panels());
assert!(cfg.usage.shows_quota_panel("cursor"));
assert!(!cfg.usage.merge_models);
assert_eq!(cfg.usage.refresh_interval, 10);
assert_eq!(cfg.usage.quota.refresh_interval, 60);
assert_eq!(cfg.analysis.refresh_interval, 10);
assert_eq!(cfg.performance.scan_threads, 0);
assert_eq!(cfg.providers, ProvidersConfig::default());
assert!(cfg.providers.cursor);
assert!(cfg.providers.grok);
assert_eq!(cfg.logging.level, LogLevel::Warn);
assert_eq!(cfg.logging.retention_days, 7);
}
#[test]
fn missing_sections_use_defaults() {
let cfg: Config = toml_edit::de::from_str("").unwrap();
assert_eq!(cfg.usage.quota.panels, default_quota_panels());
assert!(cfg.providers.cursor);
assert!(cfg.providers.grok);
assert_eq!(cfg.usage.refresh_secs(), 10);
assert_eq!(cfg.usage.quota_refresh_secs(), 60);
assert_eq!(cfg.logging, LoggingConfig::default());
assert_eq!(cfg.performance, PerformanceConfig::default());
}
#[test]
fn scan_threads_resolve_config_then_env_then_auto() {
let auto = PerformanceConfig { scan_threads: 0 };
assert_eq!(auto.resolved_scan_threads_with(16, None), 2);
assert_eq!(auto.resolved_scan_threads_with(1, None), 1);
assert_eq!(auto.resolved_scan_threads_with(16, Some("7")), 7);
assert_eq!(auto.resolved_scan_threads_with(4, Some("99")), 4);
assert_eq!(auto.resolved_scan_threads_with(16, Some("0")), 2);
assert_eq!(auto.resolved_scan_threads_with(16, Some("invalid")), 2);
let configured = PerformanceConfig { scan_threads: 12 };
assert_eq!(configured.resolved_scan_threads_with(8, Some("3")), 8);
}
#[test]
fn partial_usage_section_keeps_quota_default() {
let cfg: Config = toml_edit::de::from_str("[usage]\nmerge_models = true\n").unwrap();
assert!(cfg.usage.merge_models);
assert_eq!(cfg.usage.quota.panels, default_quota_panels());
assert_eq!(cfg.usage.quota_refresh_secs(), 60);
}
#[test]
fn quota_panels_can_be_narrowed_or_emptied() {
let cfg: Config =
toml_edit::de::from_str("[usage.quota]\npanels = [\"claude\"]\n").unwrap();
assert!(cfg.usage.shows_quota_panel("claude"));
assert!(!cfg.usage.shows_quota_panel("cursor"));
let empty: Config = toml_edit::de::from_str("[usage.quota]\npanels = []\n").unwrap();
assert!(!empty.usage.shows_quota_panel("claude"));
}
#[test]
fn refresh_interval_reads_legacy_secs() {
let text = "[usage]\nrefresh_interval_secs = 5\n[analysis]\nrefresh_interval_secs = 7\n";
let mut cfg: Config = toml_edit::de::from_str(text).unwrap();
migrate_legacy(&mut cfg, text);
assert_eq!(cfg.usage.refresh_secs(), 5);
assert_eq!(cfg.analysis.refresh_secs(), 7);
}
#[test]
fn coexisting_refresh_keys_preserve_the_rest_of_the_config() {
let text = "[general]\ndefault_time_range = \"weekly\"\n[usage]\nrefresh_interval = 5\nrefresh_interval_secs = 10\n[providers]\ncursor = false\n";
let mut cfg: Config = toml_edit::de::from_str(text).unwrap();
migrate_legacy(&mut cfg, text);
assert_eq!(cfg.usage.refresh_secs(), 5);
assert_eq!(cfg.general.default_time_range, TimeRange::Weekly);
assert!(!cfg.providers.cursor);
}
#[test]
fn migrates_legacy_quota_panels() {
let text = "[usage]\nquota_panels = [\"claude\"]\n";
let mut cfg: Config = toml_edit::de::from_str(text).unwrap();
migrate_legacy(&mut cfg, text);
assert_eq!(cfg.usage.quota.panels, vec!["claude".to_string()]);
assert!(!cfg.usage.shows_quota_panel("cursor"));
}
#[test]
fn new_quota_panels_win_over_legacy() {
let text = "[usage]\nquota_panels = [\"claude\"]\n[usage.quota]\npanels = []\n";
let mut cfg: Config = toml_edit::de::from_str(text).unwrap();
migrate_legacy(&mut cfg, text);
assert!(cfg.usage.quota.panels.is_empty());
}
#[test]
fn migrate_document_upgrades_a_full_legacy_file() {
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";
let migrated = migrate_text(legacy).unwrap().expect("legacy file changes");
assert!(migrated.starts_with("#:schema "));
assert!(!migrated.contains("quota_panels"));
assert!(!migrated.contains("refresh_interval_secs"));
assert!(migrated.contains("[usage.quota]"));
assert!(migrated.contains("panels = [\"claude\", \"codex\", \"grok\"]"));
let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
assert_eq!(cfg.usage.refresh_interval, 15);
assert_eq!(cfg.analysis.refresh_interval, 20);
assert_eq!(
cfg.usage.quota.panels,
vec![
"claude".to_string(),
"codex".to_string(),
"grok".to_string()
]
);
assert_eq!(cfg.usage.quota.refresh_interval, 60);
assert_eq!(cfg.general.version, CONFIG_VERSION);
}
#[test]
fn migrate_document_is_idempotent() {
let legacy = "[usage]\nquota_panels = [\"claude\"]\nrefresh_interval_secs = 15\n";
let once = migrate_text(legacy).unwrap().expect("first pass changes");
assert!(migrate_text(&once).unwrap().is_none());
}
#[test]
fn migrate_document_noops_on_the_generated_default() {
let current = default_document().to_string();
assert!(migrate_text(¤t).unwrap().is_none());
}
#[test]
fn migrate_document_adds_only_the_schema_directive_when_keys_are_current() {
let text = "[general]\nversion = 2\n\n[usage]\nrefresh_interval = 10\n\n[usage.quota]\npanels = [\"claude\"]\n";
let migrated = migrate_text(text).unwrap().expect("adds #:schema");
assert!(migrated.starts_with("#:schema "));
assert!(migrated.contains("refresh_interval = 10"));
assert!(migrated.contains("panels = [\"claude\"]"));
}
#[test]
fn migrate_document_drops_stale_refresh_key_when_both_present() {
let text = "#:schema x\n[usage]\nrefresh_interval = 5\nrefresh_interval_secs = 10\n";
let migrated = migrate_text(text).unwrap().expect("drops the legacy key");
assert!(!migrated.contains("refresh_interval_secs"));
let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
assert_eq!(cfg.usage.refresh_interval, 5);
}
#[test]
fn migrate_document_leaves_an_inline_usage_table_to_the_read_shim() {
let text = "usage = { quota_panels = [\"claude\"], refresh_interval_secs = 30 }\n";
let migrated = migrate_text(text).unwrap().expect("adds #:schema");
assert!(migrated.starts_with("#:schema "));
assert!(migrated.contains("quota_panels"));
assert!(migrate_text(&migrated).unwrap().is_none());
}
#[test]
fn migrate_text_errors_on_malformed_toml() {
assert!(migrate_text("= not valid").is_err());
}
#[test]
fn migrate_document_drops_a_malformed_legacy_refresh_value() {
let text = "[general]\ndefault_time_range = \"weekly\"\n[usage]\nrefresh_interval_secs = -5\n[providers]\ncursor = false\n";
let migrated = migrate_text(text).unwrap().expect("legacy file changes");
assert!(!migrated.contains("refresh_interval = -5"));
assert!(!migrated.contains("refresh_interval_secs"));
let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
assert_eq!(cfg.general.default_time_range, TimeRange::Weekly);
assert!(!cfg.providers.cursor);
assert_eq!(cfg.usage.refresh_interval, 10);
assert!(migrate_text(&migrated).unwrap().is_none());
}
#[test]
fn migrate_document_preserves_a_hand_added_comment_on_a_renamed_key() {
let text = "[usage]\n# keep me\nrefresh_interval_secs = 15\n";
let migrated = migrate_text(text).unwrap().expect("legacy file changes");
assert!(migrated.contains("# keep me"));
assert!(migrated.contains("refresh_interval = 15"));
}
#[test]
fn migrate_document_adds_the_directive_despite_a_buried_schema_line() {
let text = "[usage]\nquota_panels = [\"claude\"]\nnote = \"\"\"\n#:schema fake\n\"\"\"\n";
let migrated = migrate_text(text)
.unwrap()
.expect("adds the real directive");
assert!(migrated.starts_with("#:schema https://"));
}
#[test]
fn migrate_document_merges_panels_into_an_existing_quota_table() {
let text = "[usage]\nquota_panels = [\"claude\"]\n\n[usage.quota]\nrefresh_interval = 30\n";
let migrated = migrate_text(text).unwrap().expect("legacy file changes");
assert!(!migrated.contains("quota_panels"));
let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
assert_eq!(
cfg.usage.quota.panels,
vec!["claude".to_string(), "grok".to_string()]
);
assert_eq!(cfg.usage.quota.refresh_interval, 30);
assert!(migrate_text(&migrated).unwrap().is_none());
}
#[test]
fn migrate_document_drops_legacy_quota_panels_when_new_panels_present() {
let text = "#:schema x\n[usage]\nquota_panels = [\"gemini\"]\n\n[usage.quota]\npanels = [\"claude\"]\n";
let migrated = migrate_text(text).unwrap().expect("drops the legacy key");
assert!(!migrated.contains("quota_panels"));
let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
assert_eq!(
cfg.usage.quota.panels,
vec!["claude".to_string(), "grok".to_string()]
);
}
#[test]
fn migrate_document_skips_an_inline_quota_child() {
let text =
"#:schema x\n[usage]\nquota_panels = [\"claude\"]\nquota = { panels = [\"codex\"] }\n";
assert!(migrate_text(text).unwrap().is_none());
assert!(panels_out_of_reach(&text.parse::<DocumentMut>().unwrap()));
}
#[test]
fn backfilled_panel_is_added_once_and_stays_removed() {
let text = "#:schema x\n[general]\ndefault_time_range = \"all\"\n\n[usage.quota]\npanels = [\"claude\", \"codex\"]\n";
let migrated = migrate_text(text)
.unwrap()
.expect("version 1 file upgrades");
assert!(migrated.contains("panels = [\"claude\", \"codex\", \"grok\"]"));
assert!(migrated.contains("version = 2"));
assert!(migrate_text(&migrated).unwrap().is_none());
let pruned = migrated.replace(
"[\"claude\", \"codex\", \"grok\"]",
"[\"claude\", \"codex\"]",
);
assert!(migrate_text(&pruned).unwrap().is_none());
let cfg: Config = toml_edit::de::from_str(&pruned).unwrap();
assert!(!cfg.usage.shows_quota_panel("grok"));
}
#[test]
fn backfill_leaves_previously_removed_panels_alone() {
let text = "#:schema x\n[usage.quota]\npanels = [\"claude\"]\n";
let migrated = migrate_text(text)
.unwrap()
.expect("version 1 file upgrades");
let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
assert_eq!(
cfg.usage.quota.panels,
vec!["claude".to_string(), "grok".to_string()],
"codex / copilot / cursor were removed on purpose and stay removed"
);
}
#[test]
fn backfill_never_reopens_a_band_turned_off() {
let text = "#:schema x\n[usage.quota]\npanels = []\n";
let migrated = migrate_text(text).unwrap().expect("the marker is stamped");
let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
assert!(cfg.usage.quota.panels.is_empty());
assert_eq!(cfg.general.version, CONFIG_VERSION);
}
#[test]
fn backfill_stamps_a_file_with_no_general_section() {
let text = "#:schema x\n[providers]\ncursor = false\n";
let migrated = migrate_text(text).unwrap().expect("the marker is stamped");
let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
assert_eq!(cfg.general.version, CONFIG_VERSION);
assert!(!cfg.providers.cursor);
assert!(cfg.usage.shows_quota_panel("grok"));
assert!(migrate_text(&migrated).unwrap().is_none());
}
#[test]
fn an_empty_file_reaches_the_current_layout_in_one_pass() {
for text in ["", "# just a comment\n"] {
let migrated = migrate_text(text)
.unwrap()
.expect("an empty file is stamped");
assert!(migrated.starts_with("#:schema "));
assert!(migrated.contains("version = 2"));
assert!(
migrate_text(&migrated).unwrap().is_none(),
"one pass must be enough"
);
}
}
#[test]
fn a_panel_list_that_could_not_be_moved_is_not_marked_upgraded() {
let text =
"#:schema x\n[usage]\nquota_panels = [\"claude\"]\nquota = { refresh_interval = 30 }\n";
assert!(migrate_text(text).unwrap().is_none());
assert!(panels_out_of_reach(&text.parse::<DocumentMut>().unwrap()));
}
#[test]
fn a_hand_edited_version_value_never_resets_the_config() {
for bad in ["\"2\"", "2.5", "true", "-1"] {
let text = format!(
"[general]\nversion = {bad}\ndefault_time_range = \"weekly\"\n[providers]\ncursor = false\n"
);
let cfg: Config = toml_edit::de::from_str(&text).expect("document still parses");
assert_eq!(cfg.general.version, legacy_config_version(), "bad = {bad}");
assert_eq!(cfg.general.default_time_range, TimeRange::Weekly);
assert!(!cfg.providers.cursor);
let migrated = migrate_text(&text)
.unwrap()
.expect("the marker is repaired");
let cfg: Config = toml_edit::de::from_str(&migrated).unwrap();
assert_eq!(cfg.general.version, CONFIG_VERSION);
}
}
#[test]
fn every_backfilled_panel_ships_by_the_current_version() {
for (version, name) in PANELS_ADDED_BY_VERSION {
assert!(
*version <= CONFIG_VERSION,
"{name} is scheduled for a version this build never stamps"
);
assert!(
default_quota_panels().iter().any(|p| p == name),
"{name} is back-filled but is not a current default panel"
);
}
}
#[test]
fn refresh_secs_clamps_zero_to_one() {
let cfg = UsageConfig {
refresh_interval: 0,
quota: QuotaConfig {
refresh_interval: 0,
..QuotaConfig::default()
},
..UsageConfig::default()
};
assert_eq!(cfg.refresh_secs(), 1);
assert_eq!(cfg.quota_refresh_secs(), 1);
}
#[test]
fn providers_default_all_enabled() {
let p = ProvidersConfig::default();
assert!(
p.claude
&& p.codex
&& p.copilot
&& p.gemini
&& p.opencode
&& p.cursor
&& p.hermes
&& p.grok
);
}
#[test]
fn committed_schema_matches_generated() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../vct.schema.json");
let committed: Value =
serde_json::from_str(&std::fs::read_to_string(path).expect("vct.schema.json exists"))
.expect("vct.schema.json is valid JSON");
assert_eq!(committed, json_schema());
}
}