1use serde::Serialize;
16use std::path::{Path, PathBuf};
17use std::process::Command;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "lowercase")]
22pub enum Freshness {
23 Fresh,
25 Stale,
27 Unpinned,
29}
30
31#[derive(Debug, Clone, Serialize)]
32pub struct VerifyResult {
33 pub freshness: Freshness,
34 pub pinned: String,
36 pub dataset: Option<String>,
38 pub current_sha: String,
40 pub detail: String,
41}
42
43pub fn parse_context_version(cv: &str) -> (Option<String>, String) {
46 match cv.split_once('@') {
47 Some((dataset, pin)) => (Some(dataset.to_string()), pin.to_string()),
48 None => (None, cv.to_string()),
49 }
50}
51
52pub fn is_sha_like(pin: &str) -> bool {
54 let len = pin.len();
55 (7..=64).contains(&len) && pin.chars().all(|c| c.is_ascii_hexdigit())
56}
57
58pub fn classify(pin: &str, current_sha: &str) -> Freshness {
60 if !is_sha_like(pin) {
61 Freshness::Unpinned
62 } else if current_sha == pin || current_sha.starts_with(pin) {
63 Freshness::Fresh
64 } else {
65 Freshness::Stale
66 }
67}
68
69fn mdl_files(project: &Path) -> Result<Vec<PathBuf>, String> {
72 let mut out = Vec::new();
73 collect(project, &mut out)?;
74 out.sort();
75 Ok(out)
76}
77
78fn collect(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
79 for entry in std::fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))? {
80 let entry = entry.map_err(|e| e.to_string())?;
81 let path = entry.path();
82 let name = entry.file_name();
83 let name = name.to_string_lossy();
84 if path.is_dir() {
85 if matches!(name.as_ref(), ".git" | ".claude" | ".venv" | "node_modules") {
86 continue;
87 }
88 collect(&path, out)?;
89 } else if matches!(
90 path.extension().and_then(|e| e.to_str()),
91 Some("yml") | Some("yaml") | Some("md")
92 ) {
93 out.push(path);
94 }
95 }
96 Ok(())
97}
98
99fn blob_sha(file: &Path) -> Result<String, String> {
102 let output = Command::new("git")
103 .arg("hash-object")
104 .arg(file)
105 .output()
106 .map_err(|e| format!("git hash-object {}: {e}", file.display()))?;
107 if !output.status.success() {
108 return Err(format!(
109 "git hash-object {} failed: {}",
110 file.display(),
111 String::from_utf8_lossy(&output.stderr).trim()
112 ));
113 }
114 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
115}
116
117pub(crate) fn hash_str(s: &str) -> Result<String, String> {
122 let tmp = tempfile::NamedTempFile::new().map_err(|e| format!("tempfile: {e}"))?;
123 std::fs::write(tmp.path(), s).map_err(|e| format!("write hash input: {e}"))?;
124 blob_sha(tmp.path())
125}
126
127fn file_manifest(root: &Path, files: &[PathBuf]) -> Result<String, String> {
129 let mut manifest = String::new();
130 for file in files {
131 let rel = file.strip_prefix(root).unwrap_or(file);
132 manifest.push_str(&format!("{} {}\n", rel.display(), blob_sha(file)?));
133 }
134 Ok(manifest)
135}
136
137pub fn compute_mdl_sha(project: &Path) -> Result<String, String> {
140 let files = mdl_files(project)?;
141 if files.is_empty() {
142 return Err(format!(
143 "no MDL files (*.yml/*.yaml/*.md) under {}",
144 project.display()
145 ));
146 }
147 hash_str(&file_manifest(project, &files)?)
148}
149
150pub(crate) fn compute_dir_sha(dir: &Path) -> Result<String, String> {
156 let mut files = Vec::new();
157 all_files(dir, &mut files)?;
158 if files.is_empty() {
159 return Err(format!("no files under {}", dir.display()));
160 }
161 files.sort();
162 hash_str(&file_manifest(dir, &files)?)
163}
164
165pub(crate) fn compute_file_sha(file: &Path) -> Result<String, String> {
170 blob_sha(file)
171}
172
173fn all_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
176 for entry in std::fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))? {
177 let entry = entry.map_err(|e| e.to_string())?;
178 let path = entry.path();
179 let name = entry.file_name();
180 let name = name.to_string_lossy();
181 if path.is_dir() {
182 if matches!(name.as_ref(), ".git" | ".venv" | "node_modules" | "target") {
183 continue;
184 }
185 all_files(&path, out)?;
186 } else {
187 out.push(path);
188 }
189 }
190 Ok(())
191}
192
193pub fn verify_context(
195 golden_context_version: Option<&str>,
196 project: &Path,
197) -> Result<VerifyResult, String> {
198 let current_sha = compute_mdl_sha(project)?;
199 let cv = golden_context_version.unwrap_or("");
200 let (dataset, pinned) = parse_context_version(cv);
201 let freshness = if cv.is_empty() {
202 Freshness::Unpinned
203 } else {
204 classify(&pinned, ¤t_sha)
205 };
206 let detail = match freshness {
207 Freshness::Fresh => format!("pin '{pinned}' matches current MDL SHA"),
208 Freshness::Stale => format!(
209 "pin '{pinned}' != current MDL SHA {current_sha} — MDL changed; ground truth may be stale"
210 ),
211 Freshness::Unpinned => {
212 if cv.is_empty() {
213 format!("golden has no context_version; current MDL SHA is {current_sha}")
214 } else {
215 format!("pin '{pinned}' is symbolic (not a SHA); current MDL SHA is {current_sha}")
216 }
217 }
218 };
219 Ok(VerifyResult {
220 freshness,
221 pinned,
222 dataset,
223 current_sha,
224 detail,
225 })
226}
227
228pub fn stamp_context_version(
232 golden_text: &str,
233 sha: &str,
234 fallback_dataset: Option<&str>,
235) -> Result<String, String> {
236 let mut lines: Vec<String> = golden_text.lines().map(str::to_string).collect();
237 let idx = lines
238 .iter()
239 .position(|l| l.trim_start().starts_with("context_version:"));
240
241 let existing_dataset = idx.and_then(|i| {
243 let after = lines[i].split_once("context_version:")?.1.trim();
244 let value = after
246 .split_once('#')
247 .map(|(v, _)| v.trim())
248 .unwrap_or(after);
249 parse_context_version(value).0
250 });
251 let dataset = existing_dataset.or_else(|| fallback_dataset.map(str::to_string));
252 let new_value = match &dataset {
253 Some(d) => format!("{d}@{sha}"),
254 None => sha.to_string(),
255 };
256 let new_line = format!(
257 "context_version: {new_value} # stamped by `warble eval verify-context --stamp`"
258 );
259
260 match idx {
261 Some(i) => lines[i] = new_line,
262 None => {
263 let insert_at = lines
265 .iter()
266 .position(|l| l.trim_start().starts_with("dataset:"))
267 .map(|i| i + 1)
268 .unwrap_or(0);
269 lines.insert(insert_at, new_line);
270 }
271 }
272 Ok(format!("{}\n", lines.join("\n")))
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278 use std::fs;
279
280 #[test]
281 fn parse_splits_dataset_and_pin() {
282 assert_eq!(
283 parse_context_version("jaffle_shop@a1b2c3d"),
284 (Some("jaffle_shop".into()), "a1b2c3d".into())
285 );
286 assert_eq!(parse_context_version("bareword"), (None, "bareword".into()));
287 }
288
289 #[test]
290 fn sha_like_detection() {
291 assert!(is_sha_like("a1b2c3d")); assert!(is_sha_like("ce013625030ba8dba906f756967f9e9ca394464a")); assert!(!is_sha_like("frozen-poc")); assert!(!is_sha_like("abc")); }
296
297 #[test]
298 fn classify_fresh_stale_unpinned() {
299 let sha = "ce013625030ba8dba906f756967f9e9ca394464a";
300 assert_eq!(classify(sha, sha), Freshness::Fresh);
301 assert_eq!(
302 classify("ce01362", sha),
303 Freshness::Fresh,
304 "abbrev prefix matches"
305 );
306 assert_eq!(classify("deadbeef", sha), Freshness::Stale);
307 assert_eq!(classify("frozen-poc", sha), Freshness::Unpinned);
308 }
309
310 #[test]
311 fn mdl_change_moves_the_sha_and_marks_stale() {
312 let dir = tempfile::tempdir().unwrap();
314 fs::create_dir_all(dir.path().join("models")).unwrap();
315 fs::write(
316 dir.path().join("wren_project.yml"),
317 "catalog: c\nschema: s\n",
318 )
319 .unwrap();
320 fs::write(
321 dir.path().join("models/orders.yml"),
322 "name: orders\ncolumns: [id, amount]\n",
323 )
324 .unwrap();
325
326 let sha_before = compute_mdl_sha(dir.path()).unwrap();
327 assert_eq!(sha_before.len(), 40, "40-hex git sha");
328
329 assert_eq!(classify(&sha_before, &sha_before), Freshness::Fresh);
331
332 fs::write(
334 dir.path().join("models/orders.yml"),
335 "name: orders\ncolumns: [id, amount, discount]\n",
336 )
337 .unwrap();
338 let sha_after = compute_mdl_sha(dir.path()).unwrap();
339 assert_ne!(sha_before, sha_after, "MDL change must move the SHA");
340
341 let verify = verify_context(Some(&format!("jaffle@{sha_before}")), dir.path()).unwrap();
343 assert_eq!(verify.freshness, Freshness::Stale);
344 assert_eq!(verify.current_sha, sha_after);
345 }
346
347 #[test]
348 fn adding_a_model_file_moves_the_sha() {
349 let dir = tempfile::tempdir().unwrap();
350 fs::write(dir.path().join("a.yml"), "x: 1\n").unwrap();
351 let before = compute_mdl_sha(dir.path()).unwrap();
352 fs::write(dir.path().join("b.yml"), "y: 2\n").unwrap();
353 let after = compute_mdl_sha(dir.path()).unwrap();
354 assert_ne!(before, after, "adding an MDL file changes the manifest SHA");
355 }
356
357 #[test]
358 fn context_report_or_prompt_change_moves_the_agent_sha() {
359 let dir = tempfile::tempdir().unwrap();
360 fs::create_dir_all(dir.path().join(".claude/agents")).unwrap();
361 fs::write(
362 dir.path().join(".claude/agents/answer.md"),
363 "Context injection mode: schema-only\n",
364 )
365 .unwrap();
366 fs::write(
367 dir.path().join("context-report.json"),
368 r#"{"mode":"schema-only"}"#,
369 )
370 .unwrap();
371 let schema_only = compute_dir_sha(dir.path()).unwrap();
372
373 fs::write(
374 dir.path().join(".claude/agents/answer.md"),
375 "Context injection mode: schema+knowledge\nRULE\n",
376 )
377 .unwrap();
378 fs::write(
379 dir.path().join("context-report.json"),
380 r#"{"mode":"schema+knowledge"}"#,
381 )
382 .unwrap();
383 let with_knowledge = compute_dir_sha(dir.path()).unwrap();
384
385 assert_ne!(
386 schema_only, with_knowledge,
387 "context identity must miss cache"
388 );
389 }
390
391 #[test]
392 fn stamp_rewrites_pin_preserving_dataset_and_comments() {
393 let golden = "# header\ndataset: jaffle_shop\ncontext_version: jaffle_shop@frozen-poc # pin\ncases: []\n";
394 let stamped = stamp_context_version(golden, "abc1234def", None).unwrap();
395 assert!(stamped.contains("context_version: jaffle_shop@abc1234def"));
396 assert!(stamped.contains("# header"), "comments preserved");
397 assert!(stamped.contains("cases: []"));
398 assert!(!stamped.contains("frozen-poc"), "old pin replaced");
399 }
400
401 #[test]
402 fn stamp_inserts_when_absent_using_fallback_dataset() {
403 let golden = "dataset: shop\ncases: []\n";
404 let stamped = stamp_context_version(golden, "abc1234", Some("shop")).unwrap();
405 assert!(stamped.contains("context_version: shop@abc1234"));
406 }
407}