use std::collections::{BTreeMap, BTreeSet}; use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use glob::Pattern; use crate::Reporter; use crate::glob::{MATCH_OPTIONS, collect_paths, file_id, normalize_pattern}; use crate::graph::Task; #[derive(Debug, Clone)] pub struct Cache { root: PathBuf, } impl Cache { pub fn new(root: PathBuf) -> Self { Cache { root } } pub fn entry(&self, fingerprint: &str) -> PathBuf { self.root.join(format!("{fingerprint}.json")) } fn manifest_for( root: &Path, task: &Task, ) -> std::io::Result<(BTreeMap, Vec)> { let (matches, unmatched) = expand_outputs(root, &task.output)?; let mut manifest = BTreeMap::new(); for relative in matches { manifest_path(&root.join(&relative), &relative, &mut manifest)?; } Ok((manifest, unmatched)) } pub(crate) fn manifest_matches( &self, root: &Path, task: &Task, fingerprint: &str, ) -> std::io::Result { let entry = self.entry(fingerprint); let Ok(bytes) = std::fs::read(entry) else { return Ok(false); }; let stored: BTreeMap = serde_json::from_slice(&bytes) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; Ok(Self::manifest_for(root, task)?.0 == stored) } pub(crate) fn record_at( &self, root: &Path, task: &Task, fingerprint: &str, reporter: &dyn Reporter, task_name: &str, ) -> std::io::Result<()> { let (manifest, unmatched) = Self::manifest_for(root, task)?; for pattern in unmatched { reporter.unmatched_output(task_name, &pattern); } std::fs::create_dir_all(&self.root)?; let entry = self.entry(fingerprint); let bytes = serde_json::to_vec(&manifest) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; static SEQUENCE: AtomicU64 = AtomicU64::new(0); for _ in 0..16 { let staging = self.root.join(format!( ".tmp-{}-{}-{}.json", entry.file_name().unwrap().to_string_lossy(), std::process::id(), SEQUENCE.fetch_add(1, Ordering::SeqCst) )); let mut file = match std::fs::OpenOptions::new() .write(true) .create_new(true) .open(&staging) { Ok(file) => file, Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, Err(error) => return Err(error), }; if let Err(error) = file.write_all(&bytes) { drop(file); let _ = std::fs::remove_file(&staging); return Err(error); } drop(file); if entry.exists() { std::fs::remove_file(&entry)?; } return std::fs::rename(&staging, &entry); } Err(std::io::Error::new( std::io::ErrorKind::AlreadyExists, "Task cache staging file names are exhausted", )) } } /// The minimal matched paths (a matched directory covers its contents), /// and the patterns that matched nothing. fn expand_outputs( base: &Path, patterns: &[String], ) -> std::io::Result<(BTreeSet, Vec)> { let entries = collect_paths(base, true); let mut matched = BTreeSet::new(); let mut unmatched = Vec::new(); for pattern in patterns { let compiled = Pattern::new(&normalize_pattern(pattern)) .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; let mut any = false; for relative in &entries { if compiled.matches_with(relative, MATCH_OPTIONS) { any = true; matched.insert(relative.clone()); } } if !any { unmatched.push(pattern.clone()); } } let minimal = matched .iter() .filter(|relative| { let parts: Vec<&str> = relative.split('/').collect(); !(1..parts.len()).any(|end| matched.contains(&parts[..end].join("/"))) }) .cloned() .collect(); Ok((minimal, unmatched)) } fn manifest_path( path: &Path, relative: &str, manifest: &mut BTreeMap, ) -> std::io::Result<()> { let metadata = path.symlink_metadata()?; if metadata.is_dir() && !metadata.file_type().is_symlink() || std::fs::metadata(path).is_ok_and(|target| target.is_dir()) { manifest.insert(relative.to_owned(), "dir".into()); let mut entries: Vec<_> = std::fs::read_dir(path)?.collect::>()?; entries.sort_by_key(|entry| entry.file_name()); for entry in entries { let name = entry.file_name().to_string_lossy().into_owned(); manifest_path(&entry.path(), &format!("{relative}/{name}"), manifest)?; } return Ok(()); } manifest.insert(relative.to_owned(), file_id(path)?); Ok(()) }