use std::path::{Path, PathBuf};
use sha2::{Digest, Sha256};
const IGNORED_FILES: &[&str] = &[".flux.lock"];
const IGNORED_DIRS: &[&str] = &[
".flux-cache",
".git",
"target",
"node_modules",
"__pycache__",
".venv",
"venv",
"dist",
"build",
".mypy_cache",
".pytest_cache",
];
pub struct Cache {
project_root: PathBuf,
builds_dir: PathBuf,
}
impl Cache {
pub fn new(project_root: &Path) -> Self {
let builds_dir = project_root.join(".flux-cache").join("builds");
Cache {
project_root: project_root.to_path_buf(),
builds_dir,
}
}
pub fn source_hash(&self) -> String {
let mut entries: Vec<PathBuf> = Vec::new();
collect_files(&self.project_root, &mut entries);
self.hash_paths(entries)
}
pub fn source_hash_scoped(&self, patterns: &[String]) -> String {
if patterns.is_empty() {
return self.source_hash();
}
let mut all: Vec<PathBuf> = Vec::new();
collect_files(&self.project_root, &mut all);
let matched: Vec<PathBuf> = all
.into_iter()
.filter(|p| {
p.strip_prefix(&self.project_root).is_ok_and(|rel| {
let rel = rel.to_string_lossy().replace('\\', "/");
patterns.iter().any(|pat| glob_match(pat, &rel))
})
})
.collect();
self.hash_paths(matched)
}
fn hash_paths(&self, mut entries: Vec<PathBuf>) -> String {
entries.sort();
let mut hasher = Sha256::new();
for path in entries {
if let Ok(rel) = path.strip_prefix(&self.project_root) {
hasher.update(rel.to_string_lossy().as_bytes());
}
hasher.update([0u8]); if let Ok(bytes) = std::fs::read(&path) {
hasher.update(&bytes);
}
hasher.update([0u8]);
}
hex(&hasher.finalize())
}
pub fn is_fresh(&self, step: &str, hash: &str) -> bool {
self.stored_hash(step).as_deref() == Some(hash)
}
pub fn store(&self, step: &str, hash: &str) -> std::io::Result<()> {
std::fs::create_dir_all(&self.builds_dir)?;
std::fs::write(self.step_file(step), hash)
}
pub fn clear_builds(&self) -> std::io::Result<()> {
if self.builds_dir.exists() {
std::fs::remove_dir_all(&self.builds_dir)?;
}
Ok(())
}
fn stored_hash(&self, step: &str) -> Option<String> {
std::fs::read_to_string(self.step_file(step))
.ok()
.map(|s| s.trim().to_string())
}
fn step_file(&self, step: &str) -> PathBuf {
let safe: String = step
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
self.builds_dir.join(format!("{safe}.hash"))
}
}
fn collect_files(dir: &Path, out: &mut Vec<PathBuf>) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
let file_type = match entry.file_type() {
Ok(t) => t,
Err(_) => continue,
};
if file_type.is_dir() {
if !is_ignored(&path) {
collect_files(&path, out);
}
} else if file_type.is_file() && !is_ignored_file(&path) {
out.push(path);
}
}
}
fn is_ignored(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.map(|name| IGNORED_DIRS.contains(&name))
.unwrap_or(false)
}
fn is_ignored_file(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.map(|name| IGNORED_FILES.contains(&name))
.unwrap_or(false)
}
fn hex(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
pub fn glob_match(pattern: &str, path: &str) -> bool {
let pat_segs: Vec<&str> = pattern.split('/').collect();
let path_segs: Vec<&str> = path.split('/').collect();
if match_segments(&pat_segs, &path_segs) {
return true;
}
if pat_segs.len() == 1 {
if let Some(base) = path_segs.last() {
return segment_match(pat_segs[0], base);
}
}
false
}
fn match_segments(pat: &[&str], seg: &[&str]) -> bool {
if pat.is_empty() {
return seg.is_empty();
}
if pat[0] == "**" {
for i in 0..=seg.len() {
if match_segments(&pat[1..], &seg[i..]) {
return true;
}
}
return false;
}
if seg.is_empty() {
return false;
}
if segment_match(pat[0], seg[0]) {
return match_segments(&pat[1..], &seg[1..]);
}
false
}
fn segment_match(pat: &str, seg: &str) -> bool {
let p: Vec<char> = pat.chars().collect();
let s: Vec<char> = seg.chars().collect();
let (mut pi, mut si) = (0usize, 0usize);
let (mut star, mut mark) = (None, 0usize);
while si < s.len() {
if pi < p.len() && (p[pi] == '?' || p[pi] == s[si]) {
pi += 1;
si += 1;
} else if pi < p.len() && p[pi] == '*' {
star = Some(pi);
mark = si;
pi += 1;
} else if let Some(sp) = star {
pi = sp + 1;
mark += 1;
si = mark;
} else {
return false;
}
}
while pi < p.len() && p[pi] == '*' {
pi += 1;
}
pi == p.len()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn glob_matches_double_star() {
assert!(glob_match("frontend/**", "frontend/src/button.tsx"));
assert!(glob_match("frontend/**", "frontend/index.ts"));
assert!(!glob_match("frontend/**", "backend/main.rs"));
}
#[test]
fn glob_matches_single_star_within_segment() {
assert!(glob_match("src/*.rs", "src/main.rs"));
assert!(!glob_match("src/*.rs", "src/nested/main.rs"));
}
#[test]
fn bare_pattern_matches_basename() {
assert!(glob_match("*.rs", "src/deep/main.rs"));
assert!(glob_match("Cargo.toml", "Cargo.toml"));
assert!(!glob_match("*.rs", "src/main.py"));
}
#[test]
fn scoped_hash_ignores_unrelated_files() {
let mut dir = std::env::temp_dir();
dir.push(format!("flux-cache-scope-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("frontend")).unwrap();
std::fs::create_dir_all(dir.join("backend")).unwrap();
std::fs::write(dir.join("frontend/a.ts"), "one").unwrap();
std::fs::write(dir.join("backend/b.rs"), "two").unwrap();
let cache = Cache::new(&dir);
let patterns = vec!["frontend/**".to_string()];
let before = cache.source_hash_scoped(&patterns);
std::fs::write(dir.join("backend/b.rs"), "changed").unwrap();
assert_eq!(before, cache.source_hash_scoped(&patterns));
std::fs::write(dir.join("frontend/a.ts"), "changed").unwrap();
assert_ne!(before, cache.source_hash_scoped(&patterns));
let _ = std::fs::remove_dir_all(&dir);
}
}