use std::path::{Path, PathBuf};
use crate::report::{EvalReport, RunRecord};
use crate::report_html::generate_report;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Persist {
pub results_dir: PathBuf,
pub model: String,
pub backend: String,
pub cases_dir: String,
}
impl Persist {
pub(crate) fn new(results_dir: PathBuf, model: String) -> Self {
Self {
results_dir,
model,
backend: String::new(),
cases_dir: String::new(),
}
}
}
pub fn save_and_report(persist: &Persist, report: &EvalReport) -> anyhow::Result<PathBuf> {
let record = build_record(
persist.model.clone(),
persist.backend.clone(),
persist.cases_dir.clone(),
report,
);
write_record_and_report(&persist.results_dir, &record)
}
pub fn build_record(
model: String,
backend_kind: String,
cases_dir: String,
report: &EvalReport,
) -> RunRecord {
let (timestamp_display, timestamp_file) = timestamps();
let backend = if backend_kind.is_empty() {
report.backend.clone()
} else {
backend_kind
};
RunRecord {
model,
timestamp_display,
timestamp_file,
backend,
cases_dir,
system_prompt: report.system_prompt.clone(),
report: report.clone(),
}
}
pub fn write_record_and_report(results_dir: &Path, record: &RunRecord) -> anyhow::Result<PathBuf> {
let path = save_record(results_dir, record)?;
generate_report(results_dir)?;
Ok(path)
}
pub fn save_record(results_dir: &Path, record: &RunRecord) -> anyhow::Result<PathBuf> {
std::fs::create_dir_all(results_dir)
.map_err(|e| anyhow::anyhow!("creating results dir {}: {e}", results_dir.display()))?;
let file = results_dir.join(format!(
"{}_{}.json",
slug(&record.model),
record.timestamp_file
));
let json = serde_json::to_string_pretty(record)
.map_err(|e| anyhow::anyhow!("serializing run record: {e}"))?;
std::fs::write(&file, json).map_err(|e| anyhow::anyhow!("writing {}: {e}", file.display()))?;
Ok(file)
}
fn timestamps() -> (String, String) {
let now = chrono::Local::now();
(
now.format("%Y-%m-%d %H:%M:%S").to_string(),
now.format("%Y%m%d-%H%M%S").to_string(),
)
}
pub fn slug(model: &str) -> String {
let mut out = String::with_capacity(model.len());
let mut last_dash = false;
for c in model.chars() {
if c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-' {
out.push(c);
last_dash = false;
} else if !last_dash {
out.push('-');
last_dash = true;
}
}
let trimmed = out.trim_matches('-').to_owned();
if trimmed.is_empty() {
"model".to_owned()
} else {
trimmed
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn slug_is_filesystem_safe_and_collapses() {
assert_eq!(slug("Qwen/Qwen3:7b.gguf"), "Qwen-Qwen3-7b.gguf");
assert_eq!(slug("a b"), "a-b");
assert_eq!(slug("***"), "model");
assert_eq!(slug("ok_name-1.2"), "ok_name-1.2");
}
#[test]
fn save_record_writes_pretty_json_with_slugged_name() {
let dir = tempfile::tempdir().expect("tempdir");
let report = EvalReport::new(Vec::new(), 0.0, "local: m".into(), "sys".into());
let record = RunRecord {
model: "my/model".into(),
timestamp_display: "2026-06-18 14:03:21".into(),
timestamp_file: "20260618-140321".into(),
backend: "local".into(),
cases_dir: "cases".into(),
system_prompt: "sys".into(),
report,
};
let path = save_record(dir.path(), &record).expect("save");
assert_eq!(
path.file_name().unwrap().to_string_lossy(),
"my-model_20260618-140321.json"
);
let text = std::fs::read_to_string(&path).expect("read");
assert!(
text.contains("\n \"model\": \"my/model\""),
"pretty-printed"
);
}
}