Skip to main content

batuta/playbook/
cache.rs

1//! Lock file management and cache decision logic (PB-004)
2//!
3//! Handles lock file persistence (atomic write via temp+rename) and cache
4//! hit/miss determination with detailed invalidation reasons.
5
6use super::types::*;
7use anyhow::{Context, Result};
8use std::path::{Path, PathBuf};
9
10/// Cache decision for a stage
11#[derive(Debug, Clone)]
12pub enum CacheDecision {
13    /// Cache hit — skip execution
14    Hit,
15    /// Cache miss — must execute
16    Miss { reasons: Vec<InvalidationReason> },
17}
18
19/// Derive the lock file path from a playbook path: `.yaml` → `.lock.yaml`
20pub 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
25/// Load a lock file if it exists
26pub 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
38/// Save a lock file atomically (write to temp, then rename)
39pub 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    // Write-then-rename for crash-safe persistence
44    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
57/// Check cache status for a stage
58pub fn check_cache(
59    stage_name: &str,
60    current_cache_key: &str,
61    current_cmd_hash: &str,
62    current_deps_hashes: &[(String, String)], // (path, hash)
63    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    // Check upstream reruns
76    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    // Only completed stages can be cached
100    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    // Check cache_key match (primary check)
108    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    // Check output files still exist
126    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
139/// Diagnose why a cache key changed by comparing component hashes.
140fn 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, // forced
349            &[],
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", // cmd unchanged
387            &[("/tmp/in.txt".to_string(), "blake3:new_dep_hash".to_string())],
388            "blake3:stage_params", // params unchanged
389            &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        // Write initial lock
408        let lock1 = make_lock_file("hello", "blake3:key1");
409        save_lock_file(&lock1, &playbook_path).expect("unexpected failure");
410
411        // Write updated lock
412        let lock2 = make_lock_file("hello", "blake3:key2");
413        save_lock_file(&lock2, &playbook_path).expect("unexpected failure");
414
415        // Verify the latest write persisted
416        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        // Verify no temp files remain
422        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}