use serde::Serialize;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Freshness {
Fresh,
Stale,
Unpinned,
}
#[derive(Debug, Clone, Serialize)]
pub struct VerifyResult {
pub freshness: Freshness,
pub pinned: String,
pub dataset: Option<String>,
pub current_sha: String,
pub detail: String,
}
pub fn parse_context_version(cv: &str) -> (Option<String>, String) {
match cv.split_once('@') {
Some((dataset, pin)) => (Some(dataset.to_string()), pin.to_string()),
None => (None, cv.to_string()),
}
}
pub fn is_sha_like(pin: &str) -> bool {
let len = pin.len();
(7..=64).contains(&len) && pin.chars().all(|c| c.is_ascii_hexdigit())
}
pub fn classify(pin: &str, current_sha: &str) -> Freshness {
if !is_sha_like(pin) {
Freshness::Unpinned
} else if current_sha == pin || current_sha.starts_with(pin) {
Freshness::Fresh
} else {
Freshness::Stale
}
}
fn mdl_files(project: &Path) -> Result<Vec<PathBuf>, String> {
let mut out = Vec::new();
collect(project, &mut out)?;
out.sort();
Ok(out)
}
fn collect(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
for entry in std::fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))? {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if path.is_dir() {
if matches!(name.as_ref(), ".git" | ".claude" | ".venv" | "node_modules") {
continue;
}
collect(&path, out)?;
} else if matches!(
path.extension().and_then(|e| e.to_str()),
Some("yml") | Some("yaml") | Some("md")
) {
out.push(path);
}
}
Ok(())
}
fn blob_sha(file: &Path) -> Result<String, String> {
let output = Command::new("git")
.arg("hash-object")
.arg(file)
.output()
.map_err(|e| format!("git hash-object {}: {e}", file.display()))?;
if !output.status.success() {
return Err(format!(
"git hash-object {} failed: {}",
file.display(),
String::from_utf8_lossy(&output.stderr).trim()
));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub(crate) fn hash_str(s: &str) -> Result<String, String> {
let tmp = tempfile::NamedTempFile::new().map_err(|e| format!("tempfile: {e}"))?;
std::fs::write(tmp.path(), s).map_err(|e| format!("write hash input: {e}"))?;
blob_sha(tmp.path())
}
fn file_manifest(root: &Path, files: &[PathBuf]) -> Result<String, String> {
let mut manifest = String::new();
for file in files {
let rel = file.strip_prefix(root).unwrap_or(file);
manifest.push_str(&format!("{} {}\n", rel.display(), blob_sha(file)?));
}
Ok(manifest)
}
pub fn compute_mdl_sha(project: &Path) -> Result<String, String> {
let files = mdl_files(project)?;
if files.is_empty() {
return Err(format!(
"no MDL files (*.yml/*.yaml/*.md) under {}",
project.display()
));
}
hash_str(&file_manifest(project, &files)?)
}
pub(crate) fn compute_dir_sha(dir: &Path) -> Result<String, String> {
let mut files = Vec::new();
all_files(dir, &mut files)?;
if files.is_empty() {
return Err(format!("no files under {}", dir.display()));
}
files.sort();
hash_str(&file_manifest(dir, &files)?)
}
pub(crate) fn compute_file_sha(file: &Path) -> Result<String, String> {
blob_sha(file)
}
fn all_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
for entry in std::fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))? {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if path.is_dir() {
if matches!(name.as_ref(), ".git" | ".venv" | "node_modules" | "target") {
continue;
}
all_files(&path, out)?;
} else {
out.push(path);
}
}
Ok(())
}
pub fn verify_context(
golden_context_version: Option<&str>,
project: &Path,
) -> Result<VerifyResult, String> {
let current_sha = compute_mdl_sha(project)?;
let cv = golden_context_version.unwrap_or("");
let (dataset, pinned) = parse_context_version(cv);
let freshness = if cv.is_empty() {
Freshness::Unpinned
} else {
classify(&pinned, ¤t_sha)
};
let detail = match freshness {
Freshness::Fresh => format!("pin '{pinned}' matches current MDL SHA"),
Freshness::Stale => format!(
"pin '{pinned}' != current MDL SHA {current_sha} — MDL changed; ground truth may be stale"
),
Freshness::Unpinned => {
if cv.is_empty() {
format!("golden has no context_version; current MDL SHA is {current_sha}")
} else {
format!("pin '{pinned}' is symbolic (not a SHA); current MDL SHA is {current_sha}")
}
}
};
Ok(VerifyResult {
freshness,
pinned,
dataset,
current_sha,
detail,
})
}
pub fn stamp_context_version(
golden_text: &str,
sha: &str,
fallback_dataset: Option<&str>,
) -> Result<String, String> {
let mut lines: Vec<String> = golden_text.lines().map(str::to_string).collect();
let idx = lines
.iter()
.position(|l| l.trim_start().starts_with("context_version:"));
let existing_dataset = idx.and_then(|i| {
let after = lines[i].split_once("context_version:")?.1.trim();
let value = after
.split_once('#')
.map(|(v, _)| v.trim())
.unwrap_or(after);
parse_context_version(value).0
});
let dataset = existing_dataset.or_else(|| fallback_dataset.map(str::to_string));
let new_value = match &dataset {
Some(d) => format!("{d}@{sha}"),
None => sha.to_string(),
};
let new_line = format!(
"context_version: {new_value} # stamped by `warble eval verify-context --stamp`"
);
match idx {
Some(i) => lines[i] = new_line,
None => {
let insert_at = lines
.iter()
.position(|l| l.trim_start().starts_with("dataset:"))
.map(|i| i + 1)
.unwrap_or(0);
lines.insert(insert_at, new_line);
}
}
Ok(format!("{}\n", lines.join("\n")))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn parse_splits_dataset_and_pin() {
assert_eq!(
parse_context_version("jaffle_shop@a1b2c3d"),
(Some("jaffle_shop".into()), "a1b2c3d".into())
);
assert_eq!(parse_context_version("bareword"), (None, "bareword".into()));
}
#[test]
fn sha_like_detection() {
assert!(is_sha_like("a1b2c3d")); assert!(is_sha_like("ce013625030ba8dba906f756967f9e9ca394464a")); assert!(!is_sha_like("frozen-poc")); assert!(!is_sha_like("abc")); }
#[test]
fn classify_fresh_stale_unpinned() {
let sha = "ce013625030ba8dba906f756967f9e9ca394464a";
assert_eq!(classify(sha, sha), Freshness::Fresh);
assert_eq!(
classify("ce01362", sha),
Freshness::Fresh,
"abbrev prefix matches"
);
assert_eq!(classify("deadbeef", sha), Freshness::Stale);
assert_eq!(classify("frozen-poc", sha), Freshness::Unpinned);
}
#[test]
fn mdl_change_moves_the_sha_and_marks_stale() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("models")).unwrap();
fs::write(
dir.path().join("wren_project.yml"),
"catalog: c\nschema: s\n",
)
.unwrap();
fs::write(
dir.path().join("models/orders.yml"),
"name: orders\ncolumns: [id, amount]\n",
)
.unwrap();
let sha_before = compute_mdl_sha(dir.path()).unwrap();
assert_eq!(sha_before.len(), 40, "40-hex git sha");
assert_eq!(classify(&sha_before, &sha_before), Freshness::Fresh);
fs::write(
dir.path().join("models/orders.yml"),
"name: orders\ncolumns: [id, amount, discount]\n",
)
.unwrap();
let sha_after = compute_mdl_sha(dir.path()).unwrap();
assert_ne!(sha_before, sha_after, "MDL change must move the SHA");
let verify = verify_context(Some(&format!("jaffle@{sha_before}")), dir.path()).unwrap();
assert_eq!(verify.freshness, Freshness::Stale);
assert_eq!(verify.current_sha, sha_after);
}
#[test]
fn adding_a_model_file_moves_the_sha() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("a.yml"), "x: 1\n").unwrap();
let before = compute_mdl_sha(dir.path()).unwrap();
fs::write(dir.path().join("b.yml"), "y: 2\n").unwrap();
let after = compute_mdl_sha(dir.path()).unwrap();
assert_ne!(before, after, "adding an MDL file changes the manifest SHA");
}
#[test]
fn context_report_or_prompt_change_moves_the_agent_sha() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join(".claude/agents")).unwrap();
fs::write(
dir.path().join(".claude/agents/answer.md"),
"Context injection mode: schema-only\n",
)
.unwrap();
fs::write(
dir.path().join("context-report.json"),
r#"{"mode":"schema-only"}"#,
)
.unwrap();
let schema_only = compute_dir_sha(dir.path()).unwrap();
fs::write(
dir.path().join(".claude/agents/answer.md"),
"Context injection mode: schema+knowledge\nRULE\n",
)
.unwrap();
fs::write(
dir.path().join("context-report.json"),
r#"{"mode":"schema+knowledge"}"#,
)
.unwrap();
let with_knowledge = compute_dir_sha(dir.path()).unwrap();
assert_ne!(
schema_only, with_knowledge,
"context identity must miss cache"
);
}
#[test]
fn stamp_rewrites_pin_preserving_dataset_and_comments() {
let golden = "# header\ndataset: jaffle_shop\ncontext_version: jaffle_shop@frozen-poc # pin\ncases: []\n";
let stamped = stamp_context_version(golden, "abc1234def", None).unwrap();
assert!(stamped.contains("context_version: jaffle_shop@abc1234def"));
assert!(stamped.contains("# header"), "comments preserved");
assert!(stamped.contains("cases: []"));
assert!(!stamped.contains("frozen-poc"), "old pin replaced");
}
#[test]
fn stamp_inserts_when_absent_using_fallback_dataset() {
let golden = "dataset: shop\ncases: []\n";
let stamped = stamp_context_version(golden, "abc1234", Some("shop")).unwrap();
assert!(stamped.contains("context_version: shop@abc1234"));
}
}