use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::clock::Clock;
use crate::error::{Error, Result};
use crate::hashing::ALGORITHM;
const FORMAT: u32 = 1;
const SLUG_MAX: usize = 64;
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
enum Status {
Ok,
Failed,
}
#[derive(Debug, Serialize, Deserialize)]
struct Record {
format: u32,
algorithm: String,
command: String,
input_digest: String,
status: Status,
ran_at: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
outputs: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
probes: BTreeMap<String, String>,
}
#[derive(Default)]
pub struct Outcome<'a> {
pub digest: &'a str,
pub ok: bool,
pub outputs: &'a [PathBuf],
pub probes: BTreeMap<String, String>,
}
pub struct Cached {
pub ok: bool,
pub digest: String,
pub ran_at: u64,
pub outputs: Vec<String>,
pub probes: BTreeMap<String, String>,
pub fields: BTreeMap<String, String>,
}
#[must_use]
pub fn read(dir: &Path, command: &str) -> Option<Cached> {
let text = fs::read_to_string(record_path(dir, command)).ok()?;
let record = serde_yaml_ng::from_str::<Record>(&text).ok()?;
if record.format != FORMAT || record.algorithm != ALGORITHM || record.command != command {
return None;
}
let fields = record_fields(&record);
Some(Cached {
ok: record.status == Status::Ok,
digest: record.input_digest,
ran_at: record.ran_at,
outputs: record.outputs,
probes: record.probes,
fields,
})
}
fn record_fields(record: &Record) -> BTreeMap<String, String> {
use serde_yaml_ng::Value;
let mut fields = BTreeMap::new();
let Ok(Value::Mapping(map)) = serde_yaml_ng::to_value(record) else {
return fields;
};
for (key, value) in map {
let Some(name) = key.as_str() else { continue };
let text = match value {
Value::String(text) => text,
Value::Bool(flag) => flag.to_string(),
Value::Number(number) => number.to_string(),
Value::Null => String::new(),
_ => continue,
};
fields.insert(name.to_owned(), text);
}
fields
}
#[must_use]
pub fn is_fresh(dir: &Path, command: &str, digest: &str) -> bool {
read(dir, command).is_some_and(|cached| cached.ok && cached.digest == digest)
}
pub fn write(dir: &Path, command: &str, clock: Clock, outcome: &Outcome) {
let record = Record {
format: FORMAT,
algorithm: ALGORITHM.to_owned(),
command: command.to_owned(),
input_digest: outcome.digest.to_owned(),
status: if outcome.ok {
Status::Ok
} else {
Status::Failed
},
ran_at: clock.now_secs(),
outputs: outcome
.outputs
.iter()
.map(|output| output.display().to_string())
.collect(),
probes: outcome.probes.clone(),
};
if let Err(err) = try_write(dir, command, &record) {
log::warn!("mmz: could not write cache for `{command}`: {err}");
}
}
pub fn prune(dir: &Path, live: &BTreeSet<String>) -> Result<Vec<String>> {
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => return Err(err.into()),
};
let mut pruned = Vec::new();
for entry in entries {
let path = entry?.path();
match path.extension().and_then(|ext| ext.to_str()) {
Some("tmp") => {
let _ = fs::remove_file(&path);
}
Some("yaml") => {
if let Some(command) = record_command(&path) {
if !live.contains(command.as_str()) {
fs::remove_file(&path)?;
pruned.push(command);
}
}
}
_ => {}
}
}
pruned.sort();
Ok(pruned)
}
fn record_command(path: &Path) -> Option<String> {
let text = fs::read_to_string(path).ok()?;
Some(serde_yaml_ng::from_str::<Record>(&text).ok()?.command)
}
fn try_write(dir: &Path, command: &str, record: &Record) -> Result<()> {
fs::create_dir_all(dir)?;
let text = serde_yaml_ng::to_string(record).map_err(|err| Error::Serialize(Box::new(err)))?;
let tmp = dir.join(format!("{}.{}.tmp", slug(command), std::process::id()));
fs::write(&tmp, text)?;
if let Err(err) = fs::rename(&tmp, record_path(dir, command)) {
let _ = fs::remove_file(&tmp);
return Err(err.into());
}
Ok(())
}
fn record_path(dir: &Path, command: &str) -> PathBuf {
dir.join(format!("{}.yaml", slug(command)))
}
fn slug(command: &str) -> String {
let mut readable = String::new();
let mut last_dash = false;
for ch in command.chars() {
if ch.is_ascii_alphanumeric() {
readable.push(ch.to_ascii_lowercase());
last_dash = false;
} else if !last_dash {
readable.push('-');
last_dash = true;
}
}
let capped: String = readable.trim_matches('-').chars().take(SLUG_MAX).collect();
let stem = match capped.trim_matches('-') {
"" => "cmd",
trimmed => trimmed,
};
let digest = blake3::hash(command.as_bytes()).to_hex();
let short = digest.as_str().get(..16).unwrap_or(digest.as_str());
format!("{stem}-{short}")
}
#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use super::{Outcome, is_fresh, prune, read, slug, write as write_record};
use crate::clock::Clock;
const RAN_AT: u64 = 1_700_000_000;
fn write(dir: &Path, command: &str, digest: &str, ok: bool) {
write_record(
dir,
command,
Clock::pinned(RAN_AT),
&Outcome {
digest,
ok,
..Outcome::default()
},
);
}
#[test]
fn fresh_only_for_matching_successful_record() {
let dir = tempfile::tempdir().expect("tempdir");
let base = dir.path();
assert!(!is_fresh(base, "sh", "digest-a"), "no record yet");
write(base, "sh", "digest-a", true);
assert!(
is_fresh(base, "sh", "digest-a"),
"matching ok record is fresh"
);
assert!(!is_fresh(base, "sh", "digest-b"), "different digest misses");
write(base, "sh", "digest-a", false);
assert!(
!is_fresh(base, "sh", "digest-a"),
"failed record is never fresh"
);
}
#[test]
fn read_exposes_record_fields_for_macros() {
let dir = tempfile::tempdir().expect("tempdir");
write(dir.path(), "cargo test", "d1", true);
let cached = read(dir.path(), "cargo test").expect("record");
assert_eq!(
cached.fields.get("command").map(String::as_str),
Some("cargo test"),
"string field exposed"
);
assert_eq!(
cached.fields.get("status").map(String::as_str),
Some("ok"),
"enum field exposed in its serialized spelling"
);
assert_eq!(
cached.fields.get("input_digest").map(String::as_str),
Some("d1")
);
assert!(
cached.fields.contains_key("ran_at"),
"numeric field exposed for macros"
);
}
#[test]
fn declared_outputs_are_stored_with_the_record() {
let dir = tempfile::tempdir().expect("tempdir");
write_record(
dir.path(),
"just cover",
Clock::pinned(RAN_AT),
&Outcome {
digest: "d1",
ok: true,
outputs: &[PathBuf::from("target/coverage/lcov.info")],
..Outcome::default()
},
);
let cached = read(dir.path(), "just cover").expect("record");
assert_eq!(
cached.outputs,
vec!["target/coverage/lcov.info".to_owned()],
"the record remembers what the run promised to produce"
);
write(dir.path(), "cargo test", "d2", true);
let bare = read(dir.path(), "cargo test").expect("record");
assert!(
bare.outputs.is_empty(),
"a rule declaring no outputs records none"
);
}
#[test]
fn probe_digests_are_stored_with_the_record() {
let dir = tempfile::tempdir().expect("tempdir");
let probes: BTreeMap<String, String> = [("fmt-recipe".to_owned(), "abc".to_owned())]
.into_iter()
.collect();
write_record(
dir.path(),
"just fmt-check",
Clock::pinned(RAN_AT),
&Outcome {
digest: "d1",
ok: true,
probes: probes.clone(),
..Outcome::default()
},
);
let cached = read(dir.path(), "just fmt-check").expect("record");
assert_eq!(
cached.probes, probes,
"the record remembers what each probe printed, so a later stale verdict can name the one that moved"
);
write(dir.path(), "cargo test", "d2", true);
let bare = read(dir.path(), "cargo test").expect("record");
assert!(
bare.probes.is_empty(),
"a rule naming no probes records none"
);
}
#[test]
fn distinct_commands_get_distinct_records() {
let dir = tempfile::tempdir().expect("tempdir");
let base = dir.path();
write(base, "cargo test", "d1", true);
write(base, "cargo build", "d2", true);
assert!(is_fresh(base, "cargo test", "d1"));
assert!(is_fresh(base, "cargo build", "d2"));
}
#[test]
fn read_surfaces_the_clock_it_was_stamped_from() {
let dir = tempfile::tempdir().expect("tempdir");
write(dir.path(), "sh", "d", true);
let cached = read(dir.path(), "sh").expect("record");
assert_eq!(
cached.ran_at, RAN_AT,
"ran_at is the clock the writer was handed, not one read here"
);
}
#[test]
fn slug_is_readable_distinct_capped_and_never_empty() {
assert!(slug("cargo test").starts_with("cargo-test-"));
assert_ne!(slug("cargo test"), slug("cargo build"));
assert!(
slug("+++").starts_with("cmd-"),
"all-symbol name gets a stem"
);
let long = "x".repeat(500);
let stem = slug(&long);
assert_eq!(stem.len(), super::SLUG_MAX + 1 + 16, "stem is capped");
}
#[test]
fn write_is_atomic_and_leaves_no_temp_file() {
let dir = tempfile::tempdir().expect("tempdir");
let base = dir.path();
write(base, "cargo test", "digest-a", true);
let temps: Vec<_> = std::fs::read_dir(base)
.expect("cache dir")
.filter_map(Result::ok)
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "tmp"))
.collect();
assert!(temps.is_empty(), "rename leaves no .tmp behind");
assert!(
is_fresh(base, "cargo test", "digest-a"),
"record is readable"
);
}
#[test]
fn prune_drops_only_orphan_records() {
let dir = tempfile::tempdir().expect("tempdir");
let base = dir.path();
write(base, "cargo test", "d1", true);
write(base, "cargo build", "d2", true);
let live: BTreeSet<String> = ["cargo test".to_owned()].into_iter().collect();
let pruned = prune(base, &live).expect("prune");
assert_eq!(pruned, vec!["cargo build".to_owned()], "orphan removed");
assert!(is_fresh(base, "cargo test", "d1"), "live record kept");
assert!(read(base, "cargo build").is_none(), "orphan record gone");
}
#[test]
fn prune_on_missing_dir_is_empty() {
let dir = tempfile::tempdir().expect("tempdir");
let pruned = prune(&dir.path().join("absent"), &BTreeSet::new()).expect("prune");
assert!(pruned.is_empty(), "missing cache dir prunes nothing");
}
}