use std::io::Write;
use std::path::{Path, PathBuf};
use cargoless_proto::{Diagnostic, Severity, TreeState};
pub fn diagnostics_path(wt_root: &Path) -> PathBuf {
wt_root
.join(".cargoless")
.join("tree.cache")
.join("diagnostics")
}
fn severity_str(s: Severity) -> &'static str {
s.as_str()
}
fn severity_from_str(s: &str) -> Severity {
match s {
"error" => Severity::Error,
"warning" => Severity::Warning,
"info" => Severity::Info,
"hint" => Severity::Hint,
_ => Severity::Error,
}
}
pub fn serialize(diags: &[Diagnostic]) -> String {
let arr: Vec<serde_json::Value> = diags
.iter()
.map(|d| {
serde_json::json!({
"file": d.file_path.to_string_lossy(),
"line": d.line,
"col": d.col,
"severity": severity_str(d.severity),
"code": d.code,
"message": d.message,
"source": d.source,
})
})
.collect();
serde_json::Value::Array(arr).to_string()
}
pub fn deserialize(text: &str) -> Vec<Diagnostic> {
let Ok(serde_json::Value::Array(items)) = serde_json::from_str::<serde_json::Value>(text)
else {
return Vec::new();
};
items
.into_iter()
.filter_map(|v| {
let file = v.get("file")?.as_str()?;
Some(Diagnostic {
file_path: PathBuf::from(file),
line: v
.get("line")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0) as u32,
col: v
.get("col")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0) as u32,
severity: severity_from_str(
v.get("severity")
.and_then(serde_json::Value::as_str)
.unwrap_or("error"),
),
code: v
.get("code")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
message: v
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or("")
.to_string(),
source: v
.get("source")
.and_then(serde_json::Value::as_str)
.map(str::to_string),
})
})
.collect()
}
fn atomic_write(path: &Path, body: &str) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let tmp = path.with_extension("tmp");
if let Ok(mut f) = std::fs::File::create(&tmp) {
if f.write_all(body.as_bytes()).is_ok() {
let _ = f.flush();
let _ = std::fs::rename(&tmp, path);
}
}
}
pub fn clear(wt_root: &Path) {
atomic_write(&diagnostics_path(wt_root), &serialize(&[]));
}
pub fn persist(wt_root: &Path, tree: TreeState, diags: &[Diagnostic]) {
match tree {
TreeState::Green => clear(wt_root),
TreeState::Red => atomic_write(&diagnostics_path(wt_root), &serialize(diags)),
}
}
pub fn get_diagnostics(wt_root: &Path) -> Vec<Diagnostic> {
match std::fs::read_to_string(diagnostics_path(wt_root)) {
Ok(text) => deserialize(&text),
Err(_) => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn diag(file: &str, sev: Severity, msg: &str) -> Diagnostic {
Diagnostic {
file_path: PathBuf::from(file),
line: 142,
col: 18,
severity: sev,
code: Some("E0308".into()),
message: msg.into(),
source: Some("rustc".into()),
}
}
#[test]
fn serialize_roundtrips_including_tricky_message() {
let diags = vec![
diag(
"physics/src/orbit.rs",
Severity::Error,
"expected `f64`,\n found \"f32\" \\ x",
),
diag("isolation/src/lib.rs", Severity::Warning, "unused import"),
];
let back = deserialize(&serialize(&diags));
assert_eq!(
back, diags,
"JSON roundtrip preserves every field + tricky text"
);
}
#[test]
fn empty_red_serialises_as_empty_array() {
assert_eq!(serialize(&[]), "[]");
assert_eq!(deserialize("[]"), Vec::<Diagnostic>::new());
}
#[test]
fn deserialize_is_best_effort_never_panics() {
assert_eq!(deserialize(""), Vec::<Diagnostic>::new());
assert_eq!(deserialize("not json"), Vec::<Diagnostic>::new());
assert_eq!(deserialize("{}"), Vec::<Diagnostic>::new());
assert_eq!(deserialize(r#"[{"line":1}]"#), Vec::<Diagnostic>::new());
let one = deserialize(r#"[{"file":"a.rs","severity":"weird","message":"m"}]"#);
assert_eq!(one.len(), 1);
assert_eq!(one[0].severity, Severity::Error);
}
#[test]
fn green_overwrites_red_with_empty_sentinel_no_stale_red() {
let mut root = std::env::temp_dir();
root.push(format!(
"cargoless-ds-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
let diags = vec![diag(
"physics/src/orbit.rs",
Severity::Error,
"mismatched types",
)];
persist(&root, TreeState::Red, &diags);
assert_eq!(get_diagnostics(&root), diags, "red retains the full list");
let path = diagnostics_path(&root);
assert!(path.exists());
let red_bytes = std::fs::read_to_string(&path).unwrap();
assert!(
red_bytes.contains("mismatched types"),
"red file holds the real diagnostic bytes"
);
persist(&root, TreeState::Green, &[]);
assert!(
path.exists(),
"#176: green overwrites with [] (atomic rename-over), it does not remove"
);
let green_bytes = std::fs::read_to_string(&path).unwrap();
assert_eq!(
green_bytes, "[]",
"green file is exactly the empty sentinel"
);
assert!(
!green_bytes.contains("mismatched types"),
"no stale red bytes survive the green transition (the #176 invariant)"
);
assert_eq!(
get_diagnostics(&root),
Vec::<Diagnostic>::new(),
"query after green ⇒ empty, exactly as an absent file would read"
);
persist(&root, TreeState::Green, &[]);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "[]");
assert_eq!(get_diagnostics(&root), Vec::<Diagnostic>::new());
persist(&root, TreeState::Red, &[]);
assert!(path.exists());
assert_eq!(std::fs::read_to_string(&path).unwrap(), "[]");
assert_eq!(get_diagnostics(&root), Vec::<Diagnostic>::new());
let _ = std::fs::remove_dir_all(&root);
}
}