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, PartialEq, Eq)]
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 record = load(&record_path(dir, command))?;
if 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 load(path: &Path) -> Option<Record> {
let text = fs::read_to_string(path).ok()?;
let record = serde_yaml_ng::from_str::<Record>(&text).ok()?;
(record.format == FORMAT && record.algorithm == ALGORITHM).then_some(record)
}
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}");
}
}
fn cache_io(path: &Path, source: std::io::Error) -> Error {
Error::CacheIo {
path: path.to_path_buf(),
source,
}
}
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(source) => return Err(cache_io(dir, source)),
};
let mut pruned = Vec::new();
for entry in entries {
let path = entry.map_err(|source| cache_io(dir, source))?.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).map_err(|source| cache_io(&path, source))?;
pruned.push(command);
}
}
}
_ => {}
}
}
pruned.sort();
Ok(pruned)
}
pub fn import(from: &Path, dir: &Path, live: &BTreeSet<String>) -> Result<Vec<String>> {
let entries = match fs::read_dir(from) {
Ok(entries) => entries,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
Err(source) => return Err(cache_io(from, source)),
};
let mut imported = Vec::new();
for entry in entries {
let path = entry.map_err(|source| cache_io(from, source))?.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("yaml") {
continue;
}
let Some(record) = load(&path) else { continue };
if !live.contains(record.command.as_str()) {
continue;
}
let local = load(&record_path(dir, &record.command));
if local.is_some_and(|held| held.ran_at > record.ran_at || held == record) {
continue;
}
try_write(dir, &record.command, &record)?;
imported.push(record.command);
}
imported.sort();
Ok(imported)
}
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).map_err(|source| cache_io(dir, source))?;
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).map_err(|source| cache_io(&tmp, source))?;
let final_path = record_path(dir, command);
if let Err(source) = fs::rename(&tmp, &final_path) {
let _ = fs::remove_file(&tmp);
return Err(cache_io(&final_path, source));
}
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)]
#[path = "cache_tests.rs"]
mod tests;