use std::collections::BTreeSet; use std::path::{Component, Path, PathBuf}; use glob::Pattern; use sha2::{Digest, Sha256}; use crate::{Error, Graph, Result, collect_paths, compile_pattern, covers}; #[derive(Debug, Clone, Default, PartialEq)] pub struct WatchSet { pub patterns: Vec, /// Physically resolved env-file targets, for watching and classifying /// edits to the file that is actually loaded. pub env_files: Vec, /// The env-file paths as configured (joined to the package root, not /// resolved), so replacing a symlink along the way is also seen. pub env_aliases: Vec, /// Symlinks discovered while resolving an env file, whose /// retargeting changes which file is loaded. pub env_links: Vec, /// Missing directory components traversed while resolving, kept /// even when a later `..` removes them from the final path: creating /// one can make the configured spelling openable. pub env_deps: Vec, } impl Graph { /// The inputs a watcher should care about for `target`: the union of /// `files` patterns over its transitive dependencies, plus env files. pub fn watch_set(&self, target: &str, root: &Path) -> Result { let mut set = WatchSet::default(); let mut seen = BTreeSet::new(); let mut ancestors: Vec = Vec::new(); fn visit( graph: &Graph, name: &str, root: &Path, ancestors: &mut Vec, seen: &mut BTreeSet, set: &mut WatchSet, ) -> Result<()> { if let Some(start) = ancestors.iter().position(|a| a == name) { let mut cycle = ancestors[start..].to_vec(); cycle.push(name.to_owned()); return Err(Error::Cycle(cycle.join(" -> "))); } if !seen.insert(name.to_owned()) { return Ok(()); } let task = graph.get(name)?; ancestors.push(name.to_owned()); for dependency in &task.dependencies { visit(graph, dependency, root, ancestors, seen, set)?; } ancestors.pop(); if let Some(patterns) = &task.files { for pattern in patterns { let normalized = crate::glob::normalize_pattern(pattern); if !set .patterns .iter() .any(|known| known.as_str() == normalized) { set.patterns.push(compile_pattern(name, pattern)?); } } } if let Some(file) = &task.env_file { let alias = root.join(file); if !set.env_aliases.contains(&alias) { set.env_aliases.push(alias.clone()); } let resolution = resolve_watch_target(&alias); for dep in resolution.dependencies { if !set.env_deps.contains(&dep) { set.env_deps.push(dep); } } for link in resolution.links { if !set.env_links.contains(&link) { set.env_links.push(link); } } if let Some(target) = resolution.target.or(resolution.candidate) && !set.env_files.contains(&target) { set.env_files.push(target); } } Ok(()) } visit(self, target, root, &mut ancestors, &mut seen, &mut set)?; Ok(set) } } pub fn relative_to(root: &Path, path: &Path) -> Option { Some( path.strip_prefix(root) .ok()? .components() .map(|part| part.as_os_str().to_string_lossy()) .collect::>() .join("/"), ) } pub fn remember_directories(root: &Path, known_dirs: &mut BTreeSet, paths: &[PathBuf]) { // Fold established types into the directory history // before classifying: a path now known to be a file // must not borrow its former directory identity. for path in paths { let path = normalize_path(path); if let Some(relative) = relative_to(root, &path) { if path.is_dir() { known_dirs.insert(relative); } else if path.symlink_metadata().is_ok() { known_dirs.remove(&relative); } } } } pub fn forget_directories(root: &Path, known_dirs: &mut BTreeSet, gone: &[PathBuf]) { // Sources of removals/renames keep their historical // type for this event only. for path in gone { if let Some(relative) = relative_to(root, &normalize_path(path)) { known_dirs.remove(&relative); } } } /// Resolves `.` and `..` lexically, so config-relative spellings of the /// same file compare equal however they were written. pub fn normalize_path(path: &Path) -> PathBuf { let mut normalized = PathBuf::new(); for component in path.components() { match component { Component::CurDir => {} Component::ParentDir => { if matches!( normalized.components().next_back(), Some(Component::Normal(_)) ) { normalized.pop(); } else if !normalized.has_root() { normalized.push(".."); } } _ => normalized.push(component.as_os_str()), } } normalized } /// Resolves an env-file path to the file a watch should follow, the way /// opening it would: each existing component is canonicalized as it is /// reached, so a `..` after a symlink applies to the symlink's target. /// Traversal that opening would reject (through a missing or regular-file /// component) resolves to None instead of being silently rerouted; the /// configured alias is still watched, and the Runner still opens the /// original spelling. pub struct EnvResolution { pub target: Option, /// Where the target would be if the missing directories along the /// way existed; watched so their creation is noticed. pub candidate: Option, pub links: Vec, pub dependencies: Vec, } pub fn resolve_watch_target(path: &Path) -> EnvResolution { resolve_watch_target_inner(path, 0) } fn resolve_watch_target_inner(path: &Path, depth: usize) -> EnvResolution { let none = |links: Vec, dependencies: Vec| EnvResolution { target: None, candidate: None, links, dependencies, }; if depth > 40 { return none(Vec::new(), Vec::new()); } let components: Vec = path.components().collect(); let mut current = PathBuf::new(); let mut links: Vec = Vec::new(); let mut dependencies: Vec = Vec::new(); for (index, component) in components.iter().enumerate() { let last = index + 1 == components.len(); match component { Component::CurDir => {} Component::ParentDir => { // The directory being traversed out of decides where // the rest of the path lands: replacing it with a // symlink redirects the whole spelling, so it is a // recovery dependency like the missing/blocking cases. if current.parent().is_some() && current.is_dir() && !dependencies.contains(¤t) { dependencies.push(current.clone()); } if !current.pop() && !current.has_root() { return none(links, dependencies); } } Component::Normal(name) => { let candidate = current.join(name); match candidate.symlink_metadata() { Ok(metadata) if metadata.file_type().is_symlink() => { links.push(candidate.clone()); let Ok(target) = std::fs::read_link(&candidate) else { return none(links, dependencies); }; let target = if target.is_absolute() { target } else { current.join(&target) }; let resolved = resolve_watch_target_inner(&target, depth + 1); links.extend(resolved.links); dependencies.extend(resolved.dependencies); let Some(reached) = resolved.target.or(resolved.candidate) else { return none(links, dependencies); }; if !last && reached.symlink_metadata().is_err() { dependencies.push(reached.clone()); } if !last && reached.symlink_metadata().is_ok() && !reached.is_dir() { // Traversal is blocked by an existing // non-directory; replacing it can unblock // the spelling, so keep it watched. if !dependencies.contains(&reached) { dependencies.push(reached); } return none(links, dependencies); } // A not-yet-existing reached path is a candidate // prefix: keep walking so the remaining suffix // becomes part of the watched candidate. current = reached; } Ok(_) => { let Ok(canonical) = std::fs::canonicalize(&candidate) else { return none(links, dependencies); }; if !last && !canonical.is_dir() { if !dependencies.contains(&canonical) { dependencies.push(canonical); } return none(links, dependencies); } current = canonical; } Err(error) if error.kind() == std::io::ErrorKind::NotFound => { if last { return EnvResolution { target: Some(candidate), candidate: None, links, dependencies, }; } dependencies.push(candidate.clone()); let mut attempted = candidate.clone(); for rest in &components[index + 1..] { match rest { Component::ParentDir => { attempted.pop(); } Component::CurDir => {} _ => attempted.push(rest.as_os_str()), } } return EnvResolution { target: None, candidate: Some(attempted), links, dependencies, }; } Err(_) => return none(links, dependencies), } } _ => current.push(component.as_os_str()), } } EnvResolution { target: Some(current), candidate: None, links, dependencies, } } #[derive(Debug, PartialEq)] pub struct WatchState { fingerprint: String, config: String, env: Vec, } impl WatchState { /// Config or env-file contents moved, as opposed to input files: such a /// change is outside some tasks' cache keys, so a rerun must bypass /// the cache rather than trust it. pub fn metadata_changed(&self, baseline: &Self) -> bool { self.config != baseline.config || self.env != baseline.env } pub fn capture( graph: &Graph, root: &Path, config_source: &str, name: &str, extra_args: &[String], watch_set: &WatchSet, ) -> Self { let fingerprint = match crate::graph_fingerprint(graph, root, name, extra_args) { Ok(fingerprint) => format!("ok:{fingerprint}"), Err(error) => format!("error:{error}"), }; let config = format!("{:x}", Sha256::digest(config_source.as_bytes())); let env = watch_set .env_aliases .iter() .map(|path| { let content = match std::fs::read(path) { Ok(bytes) => format!("{:x}", Sha256::digest(&bytes)), Err(error) => format!("unreadable:{error}"), }; format!("{}={content}", path.display()) }) .collect(); WatchState { fingerprint, config, env } } } /// The nearest existing ancestor of each env file outside the package, /// watched non-recursively. This may be above the file's parent when the /// parent does not exist yet, so coverage can be discovered (and, when it /// changes, reconciled by the watch loop) as directories appear. pub fn external_roots(root: &Path, watch_set: &WatchSet, extra: &[PathBuf]) -> Vec { // Every existing ancestor of an external path, not just its parent: // replacing a directory higher up (secrets/ in ../secrets/v/x.env) // is otherwise invisible to the watch on the nearest parent, which // stays attached to the moved inode. Identities are canonical, so a // path that reaches the package through a symlink does not register // a second, conflicting watch over the package directory itself. let canonical_root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); let mut roots = Vec::new(); let aliases: Vec = watch_set .env_aliases .iter() .map(|alias| normalize_path(alias)) .collect(); for path in watch_set .env_files .iter() .chain(&aliases) .chain(&watch_set.env_links) .chain(&watch_set.env_deps) .chain(extra.iter()) { if path.starts_with(root) { continue; } for ancestor in path.ancestors().skip(1) { if ancestor == root { continue; } let Ok(canonical) = std::fs::canonicalize(ancestor) else { continue; }; if canonical == canonical_root || canonical.starts_with(&canonical_root) { continue; } if !roots.contains(&canonical) { roots.push(canonical); } } } roots } /// Directories currently under the package root, remembered so a /// directory that later vanishes (moves out, is removed) is still /// recognized as a directory event for the patterns it could feed. pub fn known_directories(root: &Path) -> BTreeSet { fn walk(directory: &Path, prefix: &str, dirs: &mut BTreeSet) { let Ok(entries) = std::fs::read_dir(directory) else { return; }; for entry in entries.filter_map(std::result::Result::ok) { if !entry.file_type().is_ok_and(|kind| kind.is_dir()) { continue; } let name = entry.file_name().to_string_lossy().into_owned(); let relative = if prefix.is_empty() { name } else { format!("{prefix}/{name}") }; dirs.insert(relative.clone()); walk(&entry.path(), &relative, dirs); } } let mut dirs = BTreeSet::new(); walk(root, "", &mut dirs); dirs } /// Resolved targets and intermediate links of matched input files that /// are themselves symlinks out of (or within) the package. pub fn resolved_input_paths(root: &Path, watch_set: &WatchSet) -> Vec { let mut paths = Vec::new(); for relative in collect_paths(root, false) { let matched = watch_set .patterns .iter() .any(|pattern| covers(pattern, &relative)); if !matched { continue; } let absolute = root.join(&relative); if absolute .symlink_metadata() .is_ok_and(|metadata| metadata.file_type().is_symlink()) { for path in resolution_paths(&absolute) { if !paths.contains(&path) { paths.push(path); } } } } paths } /// The paths a symlinked file resolves through and to, watched so the /// target's edits are seen even when it lives outside the package. pub fn resolution_paths(path: &Path) -> Vec { let resolution = resolve_watch_target(path); let mut paths = resolution.links; for dep in resolution.dependencies { if !paths.contains(&dep) { paths.push(dep); } } if let Some(target) = resolution.target.or(resolution.candidate) && !paths.contains(&target) { paths.push(target); } paths } /// What an event asks of the watch loop: re-evaluate content, and/or /// repair the watcher installation (topology changed under it). These /// are independent: an input ancestor being replaced needs both. #[derive(Debug, Clone, Copy, Default)] pub struct Relevance { pub evaluate: bool, pub rewatch: bool, } /// Coverage identities used by static-config watch classification. pub struct WatchContext<'a> { pub root: &'a Path, pub config_path: &'a Path, pub config_paths: &'a [PathBuf], pub input_paths: &'a [PathBuf], pub known_dirs: &'a BTreeSet, } /// Filesystem event facts, independent of a particular watcher backend. pub struct WatchEvent<'a> { pub paths: &'a [PathBuf], pub structural: bool, pub has_directory: bool, pub is_folder_event: bool, } impl WatchSet { pub fn classify(&self, context: WatchContext<'_>, event: WatchEvent<'_>) -> Option { let WatchContext { root, config_path, config_paths, input_paths, known_dirs, } = context; let structural = event.structural; // Prefix matching below is deliberately conservative and is reserved // for structural changes: an ordinary write to an unrelated file // under a pattern's tree must not postpone the debounce forever. let mut relevance: Option = None; let merge = |relevance: &mut Option, evaluate: bool, rewatch: bool| { let entry = relevance.get_or_insert_default(); entry.evaluate |= evaluate; entry.rewatch |= rewatch; }; for path in event.paths { let path = normalize_path(path); if path == config_path || self.env_files.contains(&path) || self.env_links.contains(&path) || self.env_deps.contains(&path) || self .env_aliases .iter() .any(|alias| normalize_path(alias) == path) { merge(&mut relevance, true, true); } if config_paths.contains(&path) || config_paths.iter().any(|config| config.starts_with(&path)) { merge(&mut relevance, true, true); } if input_paths.contains(&path) || input_paths.iter().any(|input| input.starts_with(&path)) { // A structural change above a resolved input needs its // coverage rebuilt as well as its content re-read. merge(&mut relevance, true, structural); } if config_path.starts_with(&path) || self .env_files .iter() .chain(&self.env_links) .chain(&self.env_deps) .any(|env| env.starts_with(&path)) || self.env_aliases.iter().any(|alias| { alias.starts_with(&path) || normalize_path(alias).starts_with(&path) }) { merge(&mut relevance, true, true); } let Some(relative) = relative_to(root, &path) else { continue; }; if relative.is_empty() { merge(&mut relevance, true, false); continue; } if self .patterns .iter() .any(|pattern| covers(pattern, &relative)) || ((path.is_dir() || event.has_directory || known_dirs.contains(&relative) || event.is_folder_event || (structural && self.patterns.iter().any(|pattern| { relative_is_above_literal(pattern.as_str(), &relative) }))) && self .patterns .iter() .any(|pattern| pattern_could_match(pattern, &relative))) { merge(&mut relevance, true, false); } } relevance } } fn literal_prefix(pattern: &str) -> impl Iterator { pattern .split('/') .take_while(|segment| !segment.contains(['*', '?', '['])) } fn pattern_could_match(pattern: &Pattern, relative: &str) -> bool { if covers(pattern, relative) { return true; } let literal: Vec<&str> = literal_prefix(pattern.as_str()).collect(); let relative_parts: Vec<&str> = relative.split('/').collect(); let is_prefix = |parts: &[&str], prefix: &[&str]| { prefix.len() <= parts.len() && parts[..prefix.len()] == *prefix }; is_prefix(&literal, &relative_parts) || is_prefix(&relative_parts, &literal) } /// Whether `relative` is a pattern's literal prefix or a directory above /// it: moving or removing that directory carries the pattern's tree. fn relative_is_above_literal(normalized: &str, relative: &str) -> bool { let literal: Vec<&str> = literal_prefix(normalized).collect(); let relative_parts: Vec<&str> = relative.split('/').collect(); relative_parts.len() <= literal.len() && literal[..relative_parts.len()] == relative_parts[..] } impl WatchSet { /// Callback graphs may discover inputs not named by the current graph. pub fn is_relevant_to_callback(&self, root: &Path, path: &Path, structural: bool) -> bool { let path = normalize_path(path); if self .env_files .iter() .chain(&self.env_links) .chain(&self.env_deps) .any(|env| *env == path || (structural && env.starts_with(&path))) || self.env_aliases.iter().any(|alias| { let alias = normalize_path(alias); alias == path || (structural && alias.starts_with(&path)) }) { return true; } let Some(relative) = relative_to(root, &path) else { return false; }; if self .patterns .iter() .any(|pattern| covers(pattern, &relative)) { return true; } // A created, removed, or renamed entry cannot match the current // graph's patterns if the graph does not know about it yet, but it // can change what a re-evaluated graph enumerates. Treat structural // events in or above a directory that holds inputs as relevant. if structural { let relative_dir = match relative.rsplit_once('/') { Some((dir, _)) => dir.to_owned(), None => String::new(), }; return self .patterns .iter() .map(|pattern| literal_dir(pattern.as_str())) .any(|dir| { dir.is_empty() || relative.is_empty() || relative == dir || dir.starts_with(&format!("{relative}/")) || relative_dir == dir || relative_dir.starts_with(&format!("{dir}/")) || dir.starts_with(&format!("{relative_dir}/")) }); } false } } /// The directory a pattern's literal prefix names: everything before the /// first glob segment, minus a trailing literal file segment. fn literal_dir(pattern: &str) -> String { let mut literal: Vec<_> = literal_prefix(pattern).collect(); if literal.len() == pattern.split('/').count() && literal.len() > 1 { literal.pop(); } literal.join("/") }