1use super::types::*;
7use anyhow::{Context, Result};
8use std::path::{Path, PathBuf};
9
10#[derive(Debug, Clone)]
12pub enum CacheDecision {
13 Hit,
15 Miss { reasons: Vec<InvalidationReason> },
17}
18
19pub fn lock_file_path(playbook_path: &Path) -> PathBuf {
21 let stem = playbook_path.file_stem().unwrap_or_default().to_string_lossy();
22 playbook_path.with_file_name(format!("{}.lock.yaml", stem))
23}
24
25pub fn load_lock_file(playbook_path: &Path) -> Result<Option<LockFile>> {
27 let path = lock_file_path(playbook_path);
28 if !path.exists() {
29 return Ok(None);
30 }
31 let content = std::fs::read_to_string(&path)
32 .with_context(|| format!("failed to read lock file: {}", path.display()))?;
33 let lock: LockFile = serde_yaml_ng::from_str(&content)
34 .with_context(|| format!("failed to parse lock file: {}", path.display()))?;
35 Ok(Some(lock))
36}
37
38pub fn save_lock_file(lock: &LockFile, playbook_path: &Path) -> Result<()> {
40 let path = lock_file_path(playbook_path);
41 let yaml = serde_yaml_ng::to_string(lock).context("failed to serialize lock file")?;
42
43 let parent = path.parent().unwrap_or(Path::new("."));
45 let temp_path = parent
46 .join(format!(".{}.tmp", path.file_name().expect("file_name missing").to_string_lossy()));
47
48 std::fs::write(&temp_path, yaml.as_bytes())
49 .with_context(|| format!("failed to write temp lock file: {}", temp_path.display()))?;
50
51 std::fs::rename(&temp_path, &path)
52 .with_context(|| format!("failed to rename lock file: {}", path.display()))?;
53
54 Ok(())
55}
56
57pub fn check_cache(
59 stage_name: &str,
60 current_cache_key: &str,
61 current_cmd_hash: &str,
62 current_deps_hashes: &[(String, String)], current_params_hash: &str,
64 lock: &Option<LockFile>,
65 forced: bool,
66 upstream_rerun: &[String],
67) -> CacheDecision {
68 let mut reasons = Vec::new();
69
70 if forced {
71 reasons.push(InvalidationReason::Forced);
72 return CacheDecision::Miss { reasons };
73 }
74
75 for stage in upstream_rerun {
77 reasons.push(InvalidationReason::UpstreamRerun { stage: stage.clone() });
78 }
79 if !reasons.is_empty() {
80 return CacheDecision::Miss { reasons };
81 }
82
83 let lock = match lock {
84 Some(l) => l,
85 None => {
86 reasons.push(InvalidationReason::NoLockFile);
87 return CacheDecision::Miss { reasons };
88 }
89 };
90
91 let stage_lock = match lock.stages.get(stage_name) {
92 Some(sl) => sl,
93 None => {
94 reasons.push(InvalidationReason::StageNotInLock);
95 return CacheDecision::Miss { reasons };
96 }
97 };
98
99 if stage_lock.status != StageStatus::Completed {
101 reasons.push(InvalidationReason::PreviousRunIncomplete {
102 status: format!("{:?}", stage_lock.status).to_lowercase(),
103 });
104 return CacheDecision::Miss { reasons };
105 }
106
107 let Some(ref old_key) = stage_lock.cache_key else {
109 reasons.push(InvalidationReason::StageNotInLock);
110 return CacheDecision::Miss { reasons };
111 };
112 if old_key != current_cache_key {
113 diagnose_key_mismatch(
114 stage_lock,
115 current_cmd_hash,
116 current_deps_hashes,
117 current_params_hash,
118 old_key,
119 current_cache_key,
120 &mut reasons,
121 );
122 return CacheDecision::Miss { reasons };
123 }
124
125 for out in &stage_lock.outs {
127 if !Path::new(&out.path).exists() {
128 reasons.push(InvalidationReason::OutputMissing { path: out.path.clone() });
129 }
130 }
131
132 if reasons.is_empty() {
133 CacheDecision::Hit
134 } else {
135 CacheDecision::Miss { reasons }
136 }
137}
138
139fn diagnose_key_mismatch(
141 stage_lock: &StageLock,
142 current_cmd_hash: &str,
143 current_deps_hashes: &[(String, String)],
144 current_params_hash: &str,
145 old_key: &str,
146 new_key: &str,
147 reasons: &mut Vec<InvalidationReason>,
148) {
149 if let Some(ref old_cmd) = stage_lock.cmd_hash {
150 if old_cmd != current_cmd_hash {
151 reasons.push(InvalidationReason::CmdChanged {
152 old: old_cmd.clone(),
153 new: current_cmd_hash.to_string(),
154 });
155 }
156 }
157
158 for (path, new_hash) in current_deps_hashes {
159 let old_hash =
160 stage_lock.deps.iter().find(|d| d.path == *path).map(|d| d.hash.as_str()).unwrap_or("");
161 if old_hash != new_hash {
162 reasons.push(InvalidationReason::DepChanged {
163 path: path.clone(),
164 old_hash: old_hash.to_string(),
165 new_hash: new_hash.clone(),
166 });
167 }
168 }
169
170 if let Some(ref old_params) = stage_lock.params_hash {
171 if old_params != current_params_hash {
172 reasons.push(InvalidationReason::ParamsChanged {
173 old: old_params.clone(),
174 new: current_params_hash.to_string(),
175 });
176 }
177 }
178
179 if reasons.is_empty() {
180 reasons.push(InvalidationReason::CacheKeyMismatch {
181 old: old_key.to_string(),
182 new: new_key.to_string(),
183 });
184 }
185}
186
187#[cfg(test)]
188#[allow(non_snake_case)]
189mod tests {
190 use super::*;
191 use indexmap::IndexMap;
192
193 fn make_lock_file(stage_name: &str, cache_key: &str) -> LockFile {
194 LockFile {
195 schema: "1.0".to_string(),
196 playbook: "test".to_string(),
197 generated_at: "2026-02-16T14:00:00Z".to_string(),
198 generator: "batuta 0.6.5".to_string(),
199 blake3_version: "1.8".to_string(),
200 params_hash: Some("blake3:params".to_string()),
201 stages: IndexMap::from([(
202 stage_name.to_string(),
203 StageLock {
204 status: StageStatus::Completed,
205 started_at: Some("2026-02-16T14:00:00Z".to_string()),
206 completed_at: Some("2026-02-16T14:00:01Z".to_string()),
207 duration_seconds: Some(1.0),
208 target: None,
209 deps: vec![DepLock {
210 path: "/tmp/in.txt".to_string(),
211 hash: "blake3:dep_hash".to_string(),
212 file_count: Some(1),
213 total_bytes: Some(100),
214 }],
215 params_hash: Some("blake3:stage_params".to_string()),
216 outs: vec![],
217 cmd_hash: Some("blake3:cmd_hash".to_string()),
218 cache_key: Some(cache_key.to_string()),
219 },
220 )]),
221 }
222 }
223
224 #[test]
225 fn test_PB004_lock_file_path_derivation() {
226 let path = lock_file_path(Path::new("/tmp/pipeline.yaml"));
227 assert_eq!(path, PathBuf::from("/tmp/pipeline.lock.yaml"));
228 }
229
230 #[test]
231 fn test_PB004_lock_file_path_nested() {
232 let path = lock_file_path(Path::new("/home/user/playbooks/build.yaml"));
233 assert_eq!(path, PathBuf::from("/home/user/playbooks/build.lock.yaml"));
234 }
235
236 #[test]
237 fn test_PB004_lock_roundtrip() {
238 let dir = tempfile::tempdir().expect("tempdir creation failed");
239 let playbook_path = dir.path().join("test.yaml");
240 std::fs::write(&playbook_path, "").expect("fs write failed");
241
242 let lock = make_lock_file("hello", "blake3:key123");
243 save_lock_file(&lock, &playbook_path).expect("unexpected failure");
244
245 let loaded = load_lock_file(&playbook_path)
246 .expect("unexpected failure")
247 .expect("unexpected failure");
248 assert_eq!(loaded.playbook, "test");
249 assert_eq!(loaded.stages["hello"].cache_key.as_deref(), Some("blake3:key123"));
250 }
251
252 #[test]
253 fn test_PB004_load_nonexistent() {
254 let result = load_lock_file(Path::new("/tmp/nonexistent_playbook.yaml"))
255 .expect("unexpected failure");
256 assert!(result.is_none());
257 }
258
259 #[test]
260 fn test_PB004_cache_hit() {
261 let lock = make_lock_file("hello", "blake3:key123");
262 let decision = check_cache(
263 "hello",
264 "blake3:key123",
265 "blake3:cmd_hash",
266 &[("/tmp/in.txt".to_string(), "blake3:dep_hash".to_string())],
267 "blake3:stage_params",
268 &Some(lock),
269 false,
270 &[],
271 );
272 assert!(matches!(decision, CacheDecision::Hit));
273 }
274
275 #[test]
276 fn test_PB004_cache_miss_no_lock() {
277 let decision = check_cache(
278 "hello",
279 "blake3:key123",
280 "blake3:cmd",
281 &[],
282 "blake3:params",
283 &None,
284 false,
285 &[],
286 );
287 match decision {
288 CacheDecision::Miss { reasons } => {
289 assert_eq!(reasons.len(), 1);
290 assert!(matches!(reasons[0], InvalidationReason::NoLockFile));
291 }
292 _ => panic!("expected miss"),
293 }
294 }
295
296 #[test]
297 fn test_PB004_cache_miss_stage_not_in_lock() {
298 let lock = make_lock_file("hello", "blake3:key123");
299 let decision = check_cache(
300 "other_stage",
301 "blake3:key123",
302 "blake3:cmd",
303 &[],
304 "blake3:params",
305 &Some(lock),
306 false,
307 &[],
308 );
309 match decision {
310 CacheDecision::Miss { reasons } => {
311 assert!(matches!(reasons[0], InvalidationReason::StageNotInLock));
312 }
313 _ => panic!("expected miss"),
314 }
315 }
316
317 #[test]
318 fn test_PB004_cache_miss_cmd_changed() {
319 let lock = make_lock_file("hello", "blake3:old_key");
320 let decision = check_cache(
321 "hello",
322 "blake3:new_key",
323 "blake3:new_cmd",
324 &[("/tmp/in.txt".to_string(), "blake3:dep_hash".to_string())],
325 "blake3:stage_params",
326 &Some(lock),
327 false,
328 &[],
329 );
330 match decision {
331 CacheDecision::Miss { reasons } => {
332 assert!(reasons.iter().any(|r| matches!(r, InvalidationReason::CmdChanged { .. })));
333 }
334 _ => panic!("expected miss"),
335 }
336 }
337
338 #[test]
339 fn test_PB004_cache_miss_forced() {
340 let lock = make_lock_file("hello", "blake3:key123");
341 let decision = check_cache(
342 "hello",
343 "blake3:key123",
344 "blake3:cmd",
345 &[],
346 "blake3:params",
347 &Some(lock),
348 true, &[],
350 );
351 match decision {
352 CacheDecision::Miss { reasons } => {
353 assert!(matches!(reasons[0], InvalidationReason::Forced));
354 }
355 _ => panic!("expected miss"),
356 }
357 }
358
359 #[test]
360 fn test_PB004_cache_miss_upstream_rerun() {
361 let lock = make_lock_file("hello", "blake3:key123");
362 let decision = check_cache(
363 "hello",
364 "blake3:key123",
365 "blake3:cmd",
366 &[],
367 "blake3:params",
368 &Some(lock),
369 false,
370 &["upstream_stage".to_string()],
371 );
372 match decision {
373 CacheDecision::Miss { reasons } => {
374 assert!(matches!(reasons[0], InvalidationReason::UpstreamRerun { .. }));
375 }
376 _ => panic!("expected miss"),
377 }
378 }
379
380 #[test]
381 fn test_PB004_cache_miss_dep_changed() {
382 let lock = make_lock_file("hello", "blake3:old_key");
383 let decision = check_cache(
384 "hello",
385 "blake3:new_key",
386 "blake3:cmd_hash", &[("/tmp/in.txt".to_string(), "blake3:new_dep_hash".to_string())],
388 "blake3:stage_params", &Some(lock),
390 false,
391 &[],
392 );
393 match decision {
394 CacheDecision::Miss { reasons } => {
395 assert!(reasons.iter().any(|r| matches!(r, InvalidationReason::DepChanged { .. })));
396 }
397 _ => panic!("expected miss"),
398 }
399 }
400
401 #[test]
402 fn test_PB004_atomic_write_survives_crash() {
403 let dir = tempfile::tempdir().expect("tempdir creation failed");
404 let playbook_path = dir.path().join("test.yaml");
405 std::fs::write(&playbook_path, "").expect("fs write failed");
406
407 let lock1 = make_lock_file("hello", "blake3:key1");
409 save_lock_file(&lock1, &playbook_path).expect("unexpected failure");
410
411 let lock2 = make_lock_file("hello", "blake3:key2");
413 save_lock_file(&lock2, &playbook_path).expect("unexpected failure");
414
415 let loaded = load_lock_file(&playbook_path)
417 .expect("unexpected failure")
418 .expect("unexpected failure");
419 assert_eq!(loaded.stages["hello"].cache_key.as_deref(), Some("blake3:key2"));
420
421 let entries: Vec<_> = std::fs::read_dir(dir.path())
423 .expect("unexpected failure")
424 .filter_map(|e| e.ok())
425 .filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
426 .collect();
427 assert!(entries.is_empty());
428 }
429}