use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::download::{cache_file_usable, purge_corrupt_cache_files, verify_cache_file, CacheIntegrity};
const KNOWN_STATE_FILES: &[&str] = &[
"messaging.json",
"models.json",
"connectors.json",
"car-connectors.json",
"agents.json",
"routing.json",
"declagents.json",
"lane-defaults.json",
"update-prefs.json",
"upgrade-cache.json",
"catalog-cache.json",
"discovered_models.json",
"a2a-peers.json",
"external-agents.jsonl",
"nudge-state.json",
"benchmark_priors.json",
"key_pool_stats.json",
"model_profiles.json",
"version.json",
];
const KNOWN_NON_JSON_FILES: &[&str] = &["env"];
const KNOWN_DIRS: &[&str] = &[
"models",
"journals",
"logs",
"agents",
"runs",
"run",
"workflow-runs",
"workflows",
"tasks",
"trajectories",
"registry",
"meetings",
"speech-runtime",
"visual-runtime",
"coder",
"projects",
"memory",
"doctor",
"bin",
];
const TOLERATED_SUFFIXES: &[&str] = &[".lock", ".tmp", ".bak", ".bin", ".jsonl"];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionStamp {
pub car_version: String,
pub state_schema_version: u32,
}
pub const STATE_SCHEMA_VERSION: u32 = 1;
impl VersionStamp {
pub fn current() -> Self {
VersionStamp {
car_version: env!("CARGO_PKG_VERSION").to_string(),
state_schema_version: STATE_SCHEMA_VERSION,
}
}
}
pub fn car_home() -> PathBuf {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
.join(".car")
}
pub fn write_version_stamp(car_home: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(car_home)?;
let stamp = VersionStamp::current();
let json = serde_json::to_string_pretty(&stamp)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let tmp = car_home.join(format!("version.json.{}.tmp", std::process::id()));
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, car_home.join("version.json"))
}
#[derive(Debug, Clone, Default)]
pub struct DoctorOptions {
pub deep: bool,
pub repair: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "status")]
pub enum StateFileStatus {
Absent,
Ok { schema_version: Option<u32> },
Unparseable {
error: String,
backed_up_to: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateFileCheck {
pub name: String,
#[serde(flatten)]
pub status: StateFileStatus,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "status")]
pub enum ModelStatus {
Healthy,
Corrupt { bad_files: Vec<String>, purged: usize },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelCheck {
pub name: String,
#[serde(flatten)]
pub status: ModelStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DoctorReport {
pub car_home: String,
pub binary_version: String,
pub on_disk_stamp: Option<VersionStamp>,
pub version_skew: bool,
pub state_files: Vec<StateFileCheck>,
pub models: Vec<ModelCheck>,
pub unrecognized: Vec<String>,
pub repairs: Vec<String>,
}
impl DoctorReport {
pub fn is_healthy(&self) -> bool {
!self.version_skew
&& self
.state_files
.iter()
.all(|f| !matches!(f.status, StateFileStatus::Unparseable { .. }))
&& self
.models
.iter()
.all(|m| matches!(m.status, ModelStatus::Healthy))
}
}
pub fn diagnose(opts: &DoctorOptions) -> DoctorReport {
diagnose_in(&car_home(), opts)
}
pub fn diagnose_in(home: &Path, opts: &DoctorOptions) -> DoctorReport {
let mut repairs = Vec::new();
let on_disk_stamp = read_version_stamp(home);
let binary = VersionStamp::current();
let version_skew = on_disk_stamp
.as_ref()
.map(|s| {
s.car_version != binary.car_version
|| s.state_schema_version != binary.state_schema_version
})
.unwrap_or(false);
let mut state_files = Vec::new();
for name in KNOWN_STATE_FILES {
state_files.push(check_state_file(home, name, opts, &mut repairs));
}
let models = check_models(&home.join("models"), opts, &mut repairs);
let unrecognized = find_unrecognized(home);
if opts.repair {
match write_version_stamp(home) {
Ok(()) => repairs.push(format!(
"refreshed version stamp to {} (schema v{})",
binary.car_version, binary.state_schema_version
)),
Err(e) => repairs.push(format!("failed to refresh version stamp: {e}")),
}
}
DoctorReport {
car_home: home.display().to_string(),
binary_version: binary.car_version,
on_disk_stamp,
version_skew,
state_files,
models,
unrecognized,
repairs,
}
}
fn read_version_stamp(home: &Path) -> Option<VersionStamp> {
let text = std::fs::read_to_string(home.join("version.json")).ok()?;
serde_json::from_str(&text).ok()
}
fn check_state_file(
home: &Path,
name: &str,
opts: &DoctorOptions,
repairs: &mut Vec<String>,
) -> StateFileCheck {
let path = home.join(name);
let is_jsonl = name.ends_with(".jsonl");
let status = match std::fs::read_to_string(&path) {
Err(_) => StateFileStatus::Absent,
Ok(text) if text.trim().is_empty() => StateFileStatus::Ok {
schema_version: None,
},
Ok(text) if is_jsonl => match jsonl_first_bad_line(&text) {
None => StateFileStatus::Ok {
schema_version: None,
},
Some(e) => unparseable(&path, name, e, opts, repairs),
},
Ok(text) => match serde_json::from_str::<serde_json::Value>(&text) {
Ok(value) => StateFileStatus::Ok {
schema_version: value
.get("schema_version")
.and_then(serde_json::Value::as_u64)
.map(|v| v as u32),
},
Err(e) => unparseable(&path, name, e.to_string(), opts, repairs),
},
};
StateFileCheck {
name: name.to_string(),
status,
}
}
fn jsonl_first_bad_line(text: &str) -> Option<String> {
for (i, line) in text.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
if let Err(e) = serde_json::from_str::<serde_json::Value>(line) {
return Some(format!("line {}: {e}", i + 1));
}
}
None
}
fn unparseable(
path: &Path,
name: &str,
error: String,
opts: &DoctorOptions,
repairs: &mut Vec<String>,
) -> StateFileStatus {
let backed_up_to = if opts.repair {
let plain = path.with_file_name(format!("{name}.corrupt.bak"));
let bak = if plain.exists() {
let epoch = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
path.with_file_name(format!("{name}.corrupt.{epoch}.bak"))
} else {
plain
};
match std::fs::rename(path, &bak) {
Ok(()) => {
repairs.push(format!("backed up unparseable {name} → {}", bak.display()));
Some(bak.display().to_string())
}
Err(err) => {
repairs.push(format!("failed to back up {name}: {err}"));
None
}
}
} else {
None
};
StateFileStatus::Unparseable {
error,
backed_up_to,
}
}
fn check_models(models_dir: &Path, opts: &DoctorOptions, repairs: &mut Vec<String>) -> Vec<ModelCheck> {
let Ok(entries) = std::fs::read_dir(models_dir) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in entries.filter_map(Result::ok) {
let dir = entry.path();
if !dir.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
if let Some(status) = check_one_model(&dir, opts, &name, repairs) {
out.push(ModelCheck { name, status });
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
fn check_one_model(
dir: &Path,
opts: &DoctorOptions,
name: &str,
repairs: &mut Vec<String>,
) -> Option<ModelStatus> {
let weights = weight_files(dir);
if weights.is_empty() {
return None;
}
let mut bad_files = Vec::new();
for w in &weights {
let corrupt = if opts.deep {
verify_cache_file(w) == CacheIntegrity::Corrupt
} else {
!cache_file_usable(w)
};
if corrupt {
bad_files.push(w.file_name().unwrap_or_default().to_string_lossy().to_string());
}
}
if bad_files.is_empty() {
return Some(ModelStatus::Healthy);
}
let purged = if opts.repair {
let n = purge_corrupt_cache_files(dir);
if n > 0 {
repairs.push(format!(
"purged {n} corrupt file(s) from model '{name}' — re-pull with `car models pull {name}`"
));
}
n
} else {
0
};
Some(ModelStatus::Corrupt { bad_files, purged })
}
fn weight_files(dir: &Path) -> Vec<PathBuf> {
fn is_weight(p: &Path) -> bool {
matches!(
p.extension().and_then(|e| e.to_str()),
Some("safetensors") | Some("gguf")
)
}
let mut out = Vec::new();
let Ok(entries) = std::fs::read_dir(dir) else {
return out;
};
for entry in entries.filter_map(Result::ok) {
let p = entry.path();
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
out.extend(weight_files(&p));
} else if is_weight(&p) {
out.push(p);
}
}
out
}
fn find_unrecognized(home: &Path) -> Vec<String> {
let Ok(entries) = std::fs::read_dir(home) else {
return Vec::new();
};
let mut out: Vec<String> = entries
.filter_map(Result::ok)
.filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string();
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
let known = if is_dir {
KNOWN_DIRS.contains(&name.as_str())
} else {
KNOWN_STATE_FILES.contains(&name.as_str())
|| KNOWN_NON_JSON_FILES.contains(&name.as_str())
|| TOLERATED_SUFFIXES.iter().any(|s| name.ends_with(s))
|| name.starts_with('.')
};
if known {
None
} else {
Some(name)
}
})
.collect();
out.sort();
out
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn opts(deep: bool, repair: bool) -> DoctorOptions {
DoctorOptions { deep, repair }
}
#[test]
fn clean_home_is_healthy() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("models.json"), "{}").unwrap();
std::fs::create_dir_all(tmp.path().join("models")).unwrap();
let r = diagnose_in(tmp.path(), &opts(false, false));
assert!(r.is_healthy(), "clean home should be healthy: {r:?}");
assert!(r.unrecognized.is_empty());
}
#[test]
fn unparseable_state_file_is_flagged_and_backed_up_on_repair() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("connectors.json"), "{not json").unwrap();
let r = diagnose_in(tmp.path(), &opts(false, false));
let c = r.state_files.iter().find(|f| f.name == "connectors.json").unwrap();
assert!(matches!(c.status, StateFileStatus::Unparseable { backed_up_to: None, .. }));
assert!(!r.is_healthy());
assert!(tmp.path().join("connectors.json").exists(), "untouched without --repair");
let r = diagnose_in(tmp.path(), &opts(false, true));
let c = r.state_files.iter().find(|f| f.name == "connectors.json").unwrap();
assert!(matches!(c.status, StateFileStatus::Unparseable { backed_up_to: Some(_), .. }));
assert!(!tmp.path().join("connectors.json").exists());
assert!(tmp.path().join("connectors.json.corrupt.bak").exists());
}
#[test]
fn dotenv_env_file_is_never_parsed_or_moved() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("env"), "ANTHROPIC_API_KEY=sk-secret\nFOO=bar\n").unwrap();
let r = diagnose_in(tmp.path(), &opts(false, true));
assert!(r.is_healthy(), "dotenv env must not make the install unhealthy");
assert!(!r.unrecognized.contains(&"env".to_string()), "env is recognized");
assert!(r.state_files.iter().all(|f| f.name != "env"), "env is never JSON-checked");
assert!(tmp.path().join("env").exists(), "repair must not move the secrets file");
assert!(!tmp.path().join("env.corrupt.bak").exists());
}
#[test]
fn empty_state_file_is_ok() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("messaging.json"), "").unwrap();
let r = diagnose_in(tmp.path(), &opts(false, false));
let c = r.state_files.iter().find(|f| f.name == "messaging.json").unwrap();
assert!(matches!(c.status, StateFileStatus::Ok { .. }));
}
#[test]
fn config_only_stub_is_skipped_not_flagged() {
let tmp = TempDir::new().unwrap();
let m = tmp.path().join("models").join("Stub");
std::fs::create_dir_all(&m).unwrap();
std::fs::write(m.join("config.json"), "{}").unwrap();
let r = diagnose_in(tmp.path(), &opts(false, false));
assert!(r.models.iter().all(|m| m.name != "Stub"), "stub should be skipped");
assert!(r.is_healthy());
}
#[cfg(unix)]
#[test]
fn corrupt_model_weight_is_purged_on_repair() {
let tmp = TempDir::new().unwrap();
let m = tmp.path().join("models").join("Qwen3-Test");
std::fs::create_dir_all(&m).unwrap();
std::os::unix::fs::symlink(m.join("gone"), m.join("model.safetensors")).unwrap();
let r = diagnose_in(tmp.path(), &opts(false, false));
let mc = r.models.iter().find(|m| m.name == "Qwen3-Test").unwrap();
assert!(matches!(mc.status, ModelStatus::Corrupt { purged: 0, .. }));
let r = diagnose_in(tmp.path(), &opts(false, true));
let mc = r.models.iter().find(|m| m.name == "Qwen3-Test").unwrap();
match &mc.status {
ModelStatus::Corrupt { purged, .. } => assert_eq!(*purged, 1),
other => panic!("expected Corrupt, got {other:?}"),
}
assert!(std::fs::symlink_metadata(m.join("model.safetensors")).is_err());
}
#[test]
fn unrecognized_entries_are_reported_not_removed() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("mystery-leftover.json"), "{}").unwrap();
std::fs::create_dir_all(tmp.path().join("old_install_dir")).unwrap();
let r = diagnose_in(tmp.path(), &opts(false, true));
assert!(r.unrecognized.contains(&"mystery-leftover.json".to_string()));
assert!(r.unrecognized.contains(&"old_install_dir".to_string()));
assert!(tmp.path().join("mystery-leftover.json").exists());
assert!(tmp.path().join("old_install_dir").exists());
}
#[test]
fn version_skew_detected() {
let tmp = TempDir::new().unwrap();
let stale = VersionStamp {
car_version: "0.0.1-ancient".to_string(),
state_schema_version: 1,
};
std::fs::write(
tmp.path().join("version.json"),
serde_json::to_string(&stale).unwrap(),
)
.unwrap();
let r = diagnose_in(tmp.path(), &opts(false, false));
assert!(r.version_skew);
assert!(!r.is_healthy());
let _ = diagnose_in(tmp.path(), &opts(false, true));
let r = diagnose_in(tmp.path(), &opts(false, false));
assert!(!r.version_skew, "stamp refreshed, skew cleared");
}
}