Skip to main content

githops_core/
cache.rs

1use anyhow::Result;
2use sha2::{Digest, Sha256};
3use std::path::{Path, PathBuf};
4
5// ---------------------------------------------------------------------------
6// Input discovery
7// ---------------------------------------------------------------------------
8
9/// Expand glob patterns relative to `root` and return sorted, deduplicated
10/// file paths.  Directories and broken globs are silently skipped.
11pub fn expand_globs(patterns: &[String], root: &Path) -> Vec<PathBuf> {
12    let mut paths = Vec::new();
13    for pattern in patterns {
14        let full = root.join(pattern).to_string_lossy().into_owned();
15        if let Ok(entries) = glob::glob(&full) {
16            for entry in entries.flatten() {
17                if entry.is_file() {
18                    paths.push(entry);
19                }
20            }
21        }
22    }
23    paths.sort();
24    paths.dedup();
25    paths
26}
27
28/// Read the contents of each path.  Files that cannot be read are silently
29/// omitted (the cache will miss on the next run, triggering a re-execution).
30pub fn read_inputs(paths: &[PathBuf]) -> Vec<(PathBuf, Vec<u8>)> {
31    paths
32        .iter()
33        .filter_map(|p| std::fs::read(p).ok().map(|c| (p.clone(), c)))
34        .collect()
35}
36
37// ---------------------------------------------------------------------------
38// Key computation
39// ---------------------------------------------------------------------------
40
41/// Compute a deterministic SHA-256 cache key from:
42/// * the command's `run` script
43/// * any extra `key` strings declared in the command cache config
44/// * the path and content of every input file (in sorted path order)
45pub fn compute_key(
46    run: &str,
47    extra_keys: &[String],
48    input_files: &[(PathBuf, Vec<u8>)],
49) -> String {
50    let mut hasher = Sha256::new();
51
52    hasher.update(run.as_bytes());
53
54    for k in extra_keys {
55        hasher.update(b"\x00key\x00");
56        hasher.update(k.as_bytes());
57    }
58
59    for (path, content) in input_files {
60        hasher.update(b"\x00file\x00");
61        hasher.update(path.to_string_lossy().as_bytes());
62        hasher.update(b"\x00");
63        hasher.update(content);
64    }
65
66    format!("{:x}", hasher.finalize())
67}
68
69// ---------------------------------------------------------------------------
70// Cache store (marker-file strategy)
71// ---------------------------------------------------------------------------
72
73/// Returns `true` when a cache entry exists for `key`, meaning the command's
74/// last run with these exact inputs succeeded.
75pub fn is_hit(key: &str, cache_dir: &Path) -> bool {
76    cache_dir.join(format!("{}.ok", key)).exists()
77}
78
79/// Persist a successful run in the cache so future invocations can skip it.
80pub fn record_hit(key: &str, cache_dir: &Path) -> Result<()> {
81    std::fs::create_dir_all(cache_dir)?;
82    // Write a marker file whose name encodes the key.
83    std::fs::write(cache_dir.join(format!("{}.ok", key)), b"")?;
84    Ok(())
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use tempfile::TempDir;
91
92    #[test]
93    fn test_compute_key_is_deterministic() {
94        let inputs = vec![(
95            std::path::PathBuf::from("src/main.rs"),
96            b"fn main() {}".to_vec(),
97        )];
98        let key1 = compute_key("cargo build", &[], &inputs);
99        let key2 = compute_key("cargo build", &[], &inputs);
100        assert_eq!(key1, key2);
101    }
102
103    #[test]
104    fn test_compute_key_differs_on_run_change() {
105        let inputs: Vec<(std::path::PathBuf, Vec<u8>)> = vec![];
106        let key1 = compute_key("cargo build", &[], &inputs);
107        let key2 = compute_key("cargo test", &[], &inputs);
108        assert_ne!(key1, key2);
109    }
110
111    #[test]
112    fn test_compute_key_differs_on_file_content_change() {
113        let inputs1 = vec![(
114            std::path::PathBuf::from("src/main.rs"),
115            b"fn main() { println!(\"v1\"); }".to_vec(),
116        )];
117        let inputs2 = vec![(
118            std::path::PathBuf::from("src/main.rs"),
119            b"fn main() { println!(\"v2\"); }".to_vec(),
120        )];
121        let key1 = compute_key("build", &[], &inputs1);
122        let key2 = compute_key("build", &[], &inputs2);
123        assert_ne!(key1, key2);
124    }
125
126    #[test]
127    fn test_compute_key_differs_on_extra_keys() {
128        let inputs: Vec<(std::path::PathBuf, Vec<u8>)> = vec![];
129        let key1 = compute_key("build", &["1.70.0".to_string()], &inputs);
130        let key2 = compute_key("build", &["1.71.0".to_string()], &inputs);
131        assert_ne!(key1, key2);
132    }
133
134    #[test]
135    fn test_compute_key_is_hex_string() {
136        let inputs: Vec<(std::path::PathBuf, Vec<u8>)> = vec![];
137        let key = compute_key("build", &[], &inputs);
138        assert_eq!(key.len(), 64); // SHA-256 → 32 bytes → 64 hex chars
139        assert!(key.chars().all(|c| c.is_ascii_hexdigit()));
140    }
141
142    #[test]
143    fn test_is_hit_false_before_recording() {
144        let dir = TempDir::new().unwrap();
145        let key = "abc123";
146        assert!(!is_hit(key, dir.path()));
147    }
148
149    #[test]
150    fn test_record_hit_and_then_is_hit() {
151        let dir = TempDir::new().unwrap();
152        let key = "deadbeef";
153        assert!(!is_hit(key, dir.path()));
154        record_hit(key, dir.path()).unwrap();
155        assert!(is_hit(key, dir.path()));
156    }
157
158    #[test]
159    fn test_record_hit_creates_ok_file() {
160        let dir = TempDir::new().unwrap();
161        record_hit("myhash", dir.path()).unwrap();
162        assert!(dir.path().join("myhash.ok").exists());
163    }
164
165    #[test]
166    fn test_is_hit_different_keys_independent() {
167        let dir = TempDir::new().unwrap();
168        record_hit("key1", dir.path()).unwrap();
169        assert!(is_hit("key1", dir.path()));
170        assert!(!is_hit("key2", dir.path()));
171    }
172
173    #[test]
174    fn test_expand_globs_finds_existing_files() {
175        let dir = TempDir::new().unwrap();
176        std::fs::write(dir.path().join("a.rs"), "fn a() {}").unwrap();
177        std::fs::write(dir.path().join("b.rs"), "fn b() {}").unwrap();
178        let pattern = format!("{}/*.rs", dir.path().to_str().unwrap());
179        let found = expand_globs(&[pattern], dir.path());
180        assert_eq!(found.len(), 2);
181    }
182
183    #[test]
184    fn test_expand_globs_empty_on_no_match() {
185        let dir = TempDir::new().unwrap();
186        let pattern = format!("{}/*.nonexistent", dir.path().to_str().unwrap());
187        let found = expand_globs(&[pattern], dir.path());
188        assert!(found.is_empty());
189    }
190
191    #[test]
192    fn test_expand_globs_deduplicates() {
193        let dir = TempDir::new().unwrap();
194        std::fs::write(dir.path().join("main.rs"), "").unwrap();
195        let path_str = dir.path().to_str().unwrap();
196        // Same glob twice
197        let patterns = vec![
198            format!("{}/*.rs", path_str),
199            format!("{}/*.rs", path_str),
200        ];
201        let found = expand_globs(&patterns, dir.path());
202        assert_eq!(found.len(), 1);
203    }
204
205    #[test]
206    fn test_read_inputs_reads_content() {
207        let dir = TempDir::new().unwrap();
208        let path = dir.path().join("test.rs");
209        std::fs::write(&path, "hello world").unwrap();
210        let result = read_inputs(&[path.clone()]);
211        assert_eq!(result.len(), 1);
212        assert_eq!(result[0].1, b"hello world");
213    }
214
215    #[test]
216    fn test_read_inputs_skips_missing_files() {
217        let missing = std::path::PathBuf::from("/nonexistent/path/file.rs");
218        let result = read_inputs(&[missing]);
219        assert!(result.is_empty());
220    }
221}