use crate::config::{Config, Settings};
use crate::dirs;
use crate::file::{self, display_path};
use crate::hash;
use crate::rand::random_string;
use crate::task::Task;
use eyre::{Result, bail};
use flate2::Compression;
use flate2::read::ZlibDecoder;
use flate2::write::ZlibEncoder;
use globwalk::{GlobWalker, GlobWalkerBuilder};
use ignore::overrides::{Override, OverrideBuilder};
use itertools::Itertools;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fs::{self, File};
use std::hash::{DefaultHasher, Hash, Hasher};
use std::io::{Read, Write};
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use walkdir::{DirEntry, Error as WalkError, WalkDir};
pub(crate) async fn remove_auto_output(task: &Task, config: &Arc<Config>) -> Result<()> {
if !task.outputs.is_auto() {
return Ok(());
}
let root = task_cwd(task, config).await?;
for output in task.outputs.paths(task, &root) {
match fs::remove_file(&output) {
Ok(()) => debug!("removed auto output file: {output}"),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(err.into()),
}
}
Ok(())
}
pub(crate) fn is_glob_pattern(path: &str) -> bool {
let glob_chars = ['*', '{', '}'];
path.chars().any(|c| glob_chars.contains(&c))
}
const MAX_BRACE_EXPANSIONS: usize = 1024;
pub(crate) fn expand_glob_braces(pattern: &str) -> Result<Vec<String>> {
struct BraceGroup {
start: usize,
end: usize,
branches: Vec<(usize, usize)>,
}
fn find_group(pattern: &str) -> Result<Option<BraceGroup>> {
let mut escaped = false;
let mut class_depth = 0;
let mut group_start = None;
let mut group_depth = 0;
let mut branch_start = 0;
let mut branches = Vec::new();
for (idx, ch) in pattern.char_indices() {
if escaped {
escaped = false;
continue;
}
if ch == '\\' && !cfg!(windows) {
escaped = true;
continue;
}
match ch {
'[' if class_depth == 0 => class_depth = 1,
']' if class_depth == 1 => class_depth = 0,
'{' if class_depth == 0 => {
if group_depth == 0 {
group_start = Some(idx);
branch_start = idx + ch.len_utf8();
}
group_depth += 1;
}
',' if class_depth == 0 && group_depth == 1 => {
branches.push((branch_start, idx));
branch_start = idx + ch.len_utf8();
}
'}' if class_depth == 0 => {
if group_depth == 0 {
bail!("unopened brace alternate in glob pattern {pattern:?}");
}
group_depth -= 1;
if group_depth == 0 {
let start = group_start.unwrap();
if !branches.is_empty() {
branches.push((branch_start, idx));
return Ok(Some(BraceGroup {
start,
end: idx,
branches,
}));
}
if let Some(mut nested) = find_group(&pattern[start + 1..idx])? {
let offset = start + 1;
nested.start += offset;
nested.end += offset;
for (branch_start, branch_end) in &mut nested.branches {
*branch_start += offset;
*branch_end += offset;
}
return Ok(Some(nested));
}
group_start = None;
branches.clear();
}
}
_ => {}
}
}
if group_depth != 0 {
bail!("unclosed brace alternate in glob pattern {pattern:?}");
}
Ok(None)
}
fn expand(pattern: &str, expanded: &mut Vec<String>) -> Result<()> {
let Some(group) = find_group(pattern)? else {
if expanded.len() >= MAX_BRACE_EXPANSIONS {
bail!(
"glob pattern expands to more than {MAX_BRACE_EXPANSIONS} alternatives: {pattern:?}"
);
}
let mut literal = String::with_capacity(pattern.len());
let mut escaped = false;
let mut class_depth = 0;
for ch in pattern.chars() {
if escaped {
escaped = false;
literal.push(ch);
continue;
}
if ch == '\\' && !cfg!(windows) {
escaped = true;
literal.push(ch);
continue;
}
match ch {
'[' if class_depth == 0 => {
class_depth = 1;
literal.push(ch);
}
']' if class_depth == 1 => {
class_depth = 0;
literal.push(ch);
}
'{' if class_depth == 0 => literal.push_str("[{]"),
'}' if class_depth == 0 => literal.push_str("[}]"),
_ => literal.push(ch),
}
}
expanded.push(literal);
return Ok(());
};
let prefix = &pattern[..group.start];
let suffix = &pattern[group.end + 1..];
for (branch_start, branch_end) in group.branches {
let branch = &pattern[branch_start..branch_end];
if branch.is_empty() {
continue;
}
expand(&format!("{prefix}{branch}{suffix}"), expanded)?;
}
Ok(())
}
let mut expanded = Vec::new();
expand(pattern, &mut expanded)?;
Ok(expanded)
}
pub(crate) fn build_source_matcher(
match_root: &Path,
task_cwd: &Path,
sources: &[String],
) -> Override {
let mut builder = OverrideBuilder::new(match_root);
for s in sources {
let normalized = normalize_pattern(match_root, task_cwd, s);
let expanded = match expand_glob_braces(&normalized) {
Ok(expanded) => expanded,
Err(e) => {
warn!("invalid source pattern {s:?}: {e}");
continue;
}
};
for normalized in expanded {
if let Err(e) = builder.add(&normalized) {
warn!("invalid source pattern {s:?}: {e}");
}
}
}
builder.build().unwrap_or_else(|e| {
warn!("failed to build source matcher: {e}");
Override::empty()
})
}
pub(crate) fn lexical_normalize(path: &Path) -> PathBuf {
let absolute = path.is_absolute();
let mut normalized = PathBuf::new();
let mut depth = 0usize;
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
if depth > 0 {
normalized.pop();
depth -= 1;
} else if !absolute {
normalized.push("..");
}
}
Component::Normal(part) => {
normalized.push(part);
depth += 1;
}
component => normalized.push(component.as_os_str()),
}
}
normalized
}
fn parent_dir_pops_glob(pattern: &Path) -> bool {
let mut stack: Vec<bool> = Vec::new();
for component in pattern.components() {
match component {
Component::CurDir => {}
Component::ParentDir if stack.pop() == Some(true) => {
return true;
}
Component::Normal(part) => {
stack.push(part.to_string_lossy().contains(['*', '?', '[', '{']));
}
_ => {}
}
}
false
}
fn pattern_from_path(path: &Path) -> Option<String> {
let mut pattern = String::new();
for component in path.components() {
let part = component.as_os_str().to_str()?;
if !pattern.is_empty() {
pattern.push('/');
}
pattern.push_str(part);
}
Some(pattern)
}
fn normalize_pattern(match_root: &Path, task_cwd: &Path, pattern: &str) -> String {
let (prefix, body) = if pattern.starts_with("\\!") {
return pattern.to_string();
} else if let Some(rest) = pattern.strip_prefix('!') {
("!", rest)
} else {
("", pattern)
};
let body_path = Path::new(body);
if parent_dir_pops_glob(body_path) {
return pattern.to_string();
}
let body_abs = if body_path.is_absolute() {
body_path.to_path_buf()
} else {
task_cwd.join(body_path)
};
let body_abs = lexical_normalize(&body_abs);
let Ok(rel) = body_abs.strip_prefix(match_root) else {
return pattern.to_string();
};
let Some(rel_str) = pattern_from_path(rel) else {
return pattern.to_string();
};
if rel_str.is_empty() {
return pattern.to_string();
}
let rel_str = if rel_str.starts_with('!') {
format!("\\{rel_str}")
} else {
rel_str.to_string()
};
format!("{prefix}{rel_str}")
}
pub(crate) fn is_source(matcher: &Override, path: &Path) -> bool {
let path = lexical_normalize(path);
if path.is_absolute() && !path.starts_with(matcher.path()) {
return true;
}
matcher.matched(&path, false).is_whitelist()
}
fn expand_trailing_globstar(pattern: &str) -> String {
let trailing_globstar = pattern == "**"
|| pattern.strip_suffix("**").is_some_and(|head| {
head.chars()
.next_back()
.is_some_and(std::path::is_separator)
});
if trailing_globstar {
format!("{pattern}/*")
} else {
pattern.to_string()
}
}
pub(crate) fn expand_enumeration_patterns(pattern: &str) -> Result<Vec<String>> {
Ok(expand_glob_braces(pattern)?
.into_iter()
.map(|alternative| expand_trailing_globstar(&alternative))
.collect())
}
pub(crate) fn source_glob_patterns(sources: &[String]) -> Vec<String> {
sources
.iter()
.filter_map(|s| {
if s.starts_with('!') {
None
} else if let Some(rest) = s.strip_prefix("\\!") {
Some(format!("!{rest}"))
} else {
Some(s.clone())
}
})
.collect()
}
pub(crate) fn glob_walk(pattern: &Path, case_insensitive: bool) -> Result<GlobWalker> {
fn has_metacharacters(component: &str) -> bool {
let mut escaped = false;
for ch in component.chars() {
if escaped {
escaped = false;
} else if ch == '\\' && !cfg!(windows) {
escaped = true;
} else if matches!(ch, '*' | '?' | '[') {
return true;
}
}
false
}
let mut base = PathBuf::new();
let mut glob_pattern = PathBuf::new();
let mut globbing = false;
let mut recursive = false;
let mut pattern_depth = 0;
for component in pattern.components() {
let text = component.as_os_str().to_string_lossy();
if !globbing && has_metacharacters(&text) {
globbing = true;
}
if globbing {
recursive |= text == "**";
pattern_depth += 1;
glob_pattern.push(component);
} else {
base.push(component);
}
}
if !globbing && let Some(file_name) = base.file_name().map(|name| name.to_os_string()) {
base.pop();
glob_pattern.push(file_name);
pattern_depth = 1;
}
while !base.as_os_str().is_empty() && !base.exists() {
let Some(file_name) = base.file_name().map(|name| name.to_os_string()) else {
break;
};
base.pop();
let mut prefixed_pattern = PathBuf::from(file_name);
prefixed_pattern.push(glob_pattern);
glob_pattern = prefixed_pattern;
pattern_depth += 1;
}
let Some(mut glob_pattern) = pattern_from_path(&glob_pattern) else {
bail!("glob pattern is not valid UTF-8: {}", pattern.display());
};
if glob_pattern.starts_with('!') {
glob_pattern.insert(0, '\\');
}
let mut builder = GlobWalkerBuilder::new(&base, glob_pattern)
.follow_links(true)
.sort_by(|a, b| a.file_name().cmp(b.file_name()))
.case_insensitive(case_insensitive);
if !recursive {
builder = builder.max_depth(pattern_depth);
}
Ok(builder.build()?)
}
pub(crate) fn prune_symlink_walk_error(
entry: std::result::Result<DirEntry, WalkError>,
) -> Result<Option<DirEntry>> {
match entry {
Ok(entry) => Ok(Some(entry)),
Err(err) if symlink_walk_error_path(&err).is_some() => Ok(None),
Err(err) => Err(err.into()),
}
}
pub(crate) fn symlink_walk_error_path(err: &WalkError) -> Option<&Path> {
let path = err.path()?;
let is_symlink =
|| fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_symlink());
if err.loop_ancestor().is_some() && is_symlink() {
return Some(path);
}
err.io_error()
.is_some_and(|error| {
error.kind() == std::io::ErrorKind::NotFound || is_filesystem_loop_error(error)
})
.then_some(path)
.filter(|_| is_symlink())
}
#[cfg(unix)]
fn is_filesystem_loop_error(error: &std::io::Error) -> bool {
error.raw_os_error() == Some(nix::errno::Errno::ELOOP as i32)
}
#[cfg(windows)]
fn is_filesystem_loop_error(error: &std::io::Error) -> bool {
use windows_sys::Win32::Foundation::{ERROR_CANT_RESOLVE_FILENAME, ERROR_CIRCULAR_DEPENDENCY};
error.raw_os_error().is_some_and(|code| {
code == ERROR_CANT_RESOLVE_FILENAME as i32 || code == ERROR_CIRCULAR_DEPENDENCY as i32
})
}
pub(crate) fn build_output_matcher(root: &Path, outputs: &[String]) -> Result<Override> {
let mut builder = OverrideBuilder::new(root);
for output in outputs {
let output = normalize_pattern(root, root, output);
for output in expand_glob_braces(&output)? {
builder.add(&output)?;
let descendant = if let Some(body) = output.strip_prefix('!') {
format!("!{body}/**")
} else if let Some(body) = output.strip_prefix("\\!") {
format!("\\!{body}/**")
} else {
format!("{output}/**")
};
if !output.ends_with("/**") {
builder.add(&descendant)?;
}
}
}
Ok(builder.build()?)
}
pub(crate) fn output_glob_patterns(outputs: &[String]) -> Vec<String> {
source_glob_patterns(outputs)
}
pub(crate) fn is_output(matcher: &Override, path: &Path, is_dir: bool) -> bool {
let path = lexical_normalize(path);
if path.is_absolute() && !path.starts_with(matcher.path()) {
return true;
}
matcher.matched(&path, is_dir).is_whitelist()
}
fn resolve_task_path(root: &Path, path: impl AsRef<Path>) -> PathBuf {
let path = path.as_ref();
if path.is_absolute() {
path.to_path_buf()
} else {
root.join(path)
}
}
fn normalize_task_cwd(path: PathBuf) -> PathBuf {
let mut normalized: PathBuf = path
.components()
.filter(|component| !matches!(component, Component::CurDir))
.collect();
if normalized.as_os_str().is_empty() && !path.as_os_str().is_empty() {
normalized.push(".");
}
normalized
}
pub(crate) async fn task_cwd(task: &Task, config: &Arc<Config>) -> Result<PathBuf> {
if let Some(d) = task.dir(config).await? {
Ok(normalize_task_cwd(d))
} else {
Ok(config
.project_root
.clone()
.or_else(|| dirs::CWD.clone())
.unwrap_or_default())
}
}
pub(crate) fn task_source_match_root(root: &Path, config: &Config) -> PathBuf {
config
.config_files
.values()
.filter_map(|cf| cf.project_root())
.filter(|pr| root.starts_with(pr) || *pr == root)
.min_by_key(|p| p.components().count())
.unwrap_or_else(|| root.to_path_buf())
}
async fn collect_source_metadatas(
task: &Task,
config: &Arc<Config>,
) -> Result<(PathBuf, PathBuf, Vec<(PathBuf, fs::Metadata)>)> {
let root = task_cwd(task, config).await?;
let match_root_owned = task_source_match_root(&root, config);
let match_root = match_root_owned.as_path();
let matcher = build_source_matcher(match_root, &root, &task.sources);
let glob_patterns = source_glob_patterns(&task.sources);
let mut source_metadatas = get_file_metadatas(&root, &glob_patterns, &matcher)?;
for config_source in task.config_sources() {
let config_path = if config_source.is_absolute() {
config_source.to_path_buf()
} else {
root.join(config_source)
};
if let Ok(meta) = config_path.metadata()
&& meta.is_file()
&& !source_metadatas.iter().any(|(p, _)| p == &config_path)
{
source_metadatas.push((config_path, meta));
}
}
Ok((root, match_root_owned, source_metadatas))
}
async fn compute_source_hash(
task: &Task,
config: &Arc<Config>,
) -> Result<Option<(String, PathBuf)>> {
if task.sources.is_empty() {
return Ok(None);
}
let use_content_hash = Settings::get().task.source_freshness_hash_contents;
let (root, _, source_metadatas) = collect_source_metadatas(task, config).await?;
if source_metadatas.is_empty() {
return Ok(None);
}
let source_hash = if use_content_hash {
let cache_path = content_hash_cache_path(task, &root);
let mut cache = load_content_hash_cache(&cache_path);
let h = file_contents_to_hash(&source_metadatas, &mut cache)?;
if let Err(e) = save_content_hash_cache(&cache_path, &cache) {
trace!("failed to save content hash cache: {e}");
}
h
} else {
file_metadatas_to_hash(&source_metadatas)
};
let source_hash_path = sources_hash_path(task, &root, use_content_hash);
Ok(Some((source_hash, source_hash_path)))
}
pub(crate) struct TaskCacheInputs {
pub source_hash: String,
pub source_paths: Vec<PathBuf>,
pub root_identity: PathBuf,
}
pub(crate) async fn task_cache_inputs(
task: &Task,
config: &Arc<Config>,
persist_content_hash_cache: bool,
) -> Result<Option<TaskCacheInputs>> {
if task.sources.is_empty() {
return Ok(None);
}
let (root, match_root, mut source_metadatas) = collect_source_metadatas(task, config).await?;
if source_metadatas.is_empty() {
return Ok(None);
}
source_metadatas.sort_by(|(a, _), (b, _)| a.cmp(b));
let cache_path = content_hash_cache_path(task, &root);
let mut cache = load_content_hash_cache(&cache_path);
let mut next = ContentHashCache::new();
let mut hasher = blake3::Hasher::new();
let mut source_paths = Vec::with_capacity(source_metadatas.len());
for (path, metadata) in source_metadatas {
let identity = match path.strip_prefix(&match_root) {
Ok(relative) => format!("workspace\0{}", relative.to_string_lossy()),
Err(_) => format!("external\0{}", path.to_string_lossy()),
};
hasher.update(&(identity.len() as u64).to_le_bytes());
hasher.update(identity.as_bytes());
let contents = match cache.get(&path) {
Some(entry) if cached_entry_matches(entry, &metadata) => entry.hash.clone(),
_ => hash::file_hash_blake3(&path, None)?,
};
hasher.update(contents.as_bytes());
next.insert(path.clone(), make_cache_entry(&metadata, contents));
source_paths.push(path.strip_prefix(&root).unwrap_or(&path).to_path_buf());
}
cache = next;
if persist_content_hash_cache && let Err(e) = save_content_hash_cache(&cache_path, &cache) {
trace!("failed to save content hash cache: {e}");
}
let root_identity = root
.strip_prefix(&match_root)
.unwrap_or(&root)
.to_path_buf();
Ok(Some(TaskCacheInputs {
source_hash: hasher.finalize().to_hex().to_string(),
source_paths,
root_identity,
}))
}
pub(crate) async fn sources_are_fresh(task: &Task, config: &Arc<Config>) -> Result<bool> {
if task.sources.is_empty() {
return Ok(false);
}
let settings = Settings::get();
let use_content_hash = settings.task.source_freshness_hash_contents;
let equal_mtime_is_fresh = settings.task.source_freshness_equal_mtime_is_fresh;
let run = async || -> Result<bool> {
let (root, _, source_metadatas) = collect_source_metadatas(task, config).await?;
if source_metadatas.is_empty() {
warn!(
"task {} has sources defined but no matching files found",
task.name
);
return Ok(false);
}
if !use_content_hash {
for (path, metadata) in &source_metadatas {
if let Ok(mtime) = metadata.modified()
&& mtime == UNIX_EPOCH
{
debug!(
"source file {} has epoch timestamp, treating as stale",
display_path(path)
);
return Ok(false);
}
}
}
let source_hash = if use_content_hash {
let cache_path = content_hash_cache_path(task, &root);
let mut cache = load_content_hash_cache(&cache_path);
let h = file_contents_to_hash(&source_metadatas, &mut cache)?;
if let Err(e) = save_content_hash_cache(&cache_path, &cache) {
trace!("failed to save content hash cache: {e}");
}
h
} else {
file_metadatas_to_hash(&source_metadatas)
};
let source_hash_path = sources_hash_path(task, &root, use_content_hash);
if let Some(dir) = source_hash_path.parent() {
file::create_dir_all(dir)?;
}
let existing_hash = source_existing_hash(task, &root, use_content_hash);
if existing_hash.as_deref().is_some_and(|h| h != source_hash) {
debug!(
"source {} hash mismatch in {}",
if use_content_hash {
"content"
} else {
"metadata"
},
source_hash_path.display()
);
return Ok(false);
}
if use_content_hash {
if existing_hash.is_none() {
debug!("no stored content hash in {}", source_hash_path.display());
return Ok(false);
}
let current_output_hash = compute_output_hash(task, &root)?;
let stored_output_hash = output_existing_hash(task, &root);
let fresh = current_output_hash.is_some()
&& current_output_hash.as_deref() == stored_output_hash.as_deref();
if fresh {
file::write(&source_hash_path, &source_hash)?;
}
return Ok(fresh);
}
let sources = get_last_modified_from_metadatas(&source_metadatas);
let outputs = get_last_modified(&root, &task.outputs.paths(task, &root))?;
trace!("sources: {sources:?}, outputs: {outputs:?}");
let fresh = match (sources, outputs) {
(Some(sources), Some(outputs)) => {
if equal_mtime_is_fresh {
sources <= outputs
} else {
sources < outputs
}
}
_ => false,
};
if fresh {
file::write(&source_hash_path, &source_hash)?;
}
Ok(fresh)
};
Ok(run().await.unwrap_or_else(|err| {
warn!("sources_are_fresh: {err:?}");
false
}))
}
pub(crate) async fn save_checksum(task: &Task, config: &Arc<Config>) -> Result<()> {
if task.sources.is_empty() {
return Ok(());
}
let root = task_cwd(task, config).await?;
if task.outputs.is_auto() {
for p in task.outputs.paths(task, &root) {
debug!("touching auto output file: {p}");
file::touch_file(&PathBuf::from(&p))?;
}
} else {
for output in output_glob_patterns(&task.outputs.paths(task, &root)) {
let output_exists = if is_glob_pattern(&output) {
expand_enumeration_patterns(&output)
.map(|patterns| {
patterns.into_iter().any(|pattern| {
let pattern = resolve_task_path(&root, pattern);
glob_walk(&pattern, false)
.map(|mut paths| {
paths.any(|entry| match entry {
Ok(_) => true,
Err(err) => symlink_walk_error_path(&err).is_some(),
})
})
.unwrap_or(false)
})
})
.unwrap_or(false)
} else {
let path = Path::new(&output);
let full_path = if path.is_relative() {
root.join(path)
} else {
path.to_path_buf()
};
fs::symlink_metadata(full_path).is_ok()
};
if !output_exists {
warn!(
"task {} did not generate expected output: {}",
task.name, output
);
}
}
}
if let Some((hash, path)) = compute_source_hash(task, config).await? {
if let Some(dir) = path.parent() {
file::create_dir_all(dir)?;
}
file::write(&path, &hash)?;
}
if Settings::get().task.source_freshness_hash_contents {
let out_path = outputs_hash_path(task, &root);
match compute_output_hash(task, &root) {
Ok(Some(h)) => {
if let Some(dir) = out_path.parent() {
file::create_dir_all(dir)?;
}
file::write(&out_path, &h)?;
}
Ok(None) => {} Err(e) => {
let _ = std::fs::remove_file(&out_path);
warn!(
"task {} output hashing failed; next run will not be skipped: {e}",
task.name
);
}
}
}
Ok(())
}
fn task_state_key(task: &Task, root: &Path) -> String {
let mut hasher = DefaultHasher::new();
task.hash(&mut hasher);
task.config_sources().hash(&mut hasher);
root.hash(&mut hasher);
task.run.hash(&mut hasher);
task.sources.hash(&mut hasher);
task.outputs.patterns().hash(&mut hasher);
format!("{:x}", hasher.finish())
}
fn sources_hash_path(task: &Task, root: &Path, content_hash: bool) -> PathBuf {
let suffix = if content_hash { "-content" } else { "" };
dirs::STATE
.join("task-sources")
.join(format!("{}{suffix}", task_state_key(task, root)))
}
pub(crate) async fn source_baseline_path(task: &Task, config: &Arc<Config>) -> Result<PathBuf> {
let root = task_cwd(task, config).await?;
Ok(sources_hash_path(
task,
&root,
Settings::get().task.source_freshness_hash_contents,
))
}
fn source_existing_hash(task: &Task, root: &Path, content_hash: bool) -> Option<String> {
let path = sources_hash_path(task, root, content_hash);
if path.exists() {
Some(file::read_to_string(&path).unwrap_or_default())
} else {
None
}
}
fn outputs_hash_path(task: &Task, root: &Path) -> PathBuf {
dirs::STATE
.join("task-sources")
.join(format!("{}-outputs", task_state_key(task, root)))
}
fn output_existing_hash(task: &Task, root: &Path) -> Option<String> {
let path = outputs_hash_path(task, root);
if path.exists() {
Some(file::read_to_string(&path).unwrap_or_default())
} else {
None
}
}
fn compute_output_hash(task: &Task, root: &Path) -> Result<Option<String>> {
let raw_patterns = task.outputs.paths(task, root);
let matcher = build_output_matcher(root, &raw_patterns)?;
let patterns_or_paths = output_glob_patterns(&raw_patterns);
if patterns_or_paths.is_empty() {
return Ok(None);
}
let (glob_pats, static_paths): (Vec<&String>, Vec<&String>) =
patterns_or_paths.iter().partition(|p| is_glob_pattern(p));
let mut entries: Vec<(PathBuf, String)> = Vec::new();
fn hash_file(path: &Path) -> Result<(PathBuf, String)> {
Ok((path.to_path_buf(), hash::file_hash_blake3(path, None)?))
}
fn push_dir_entries(
dir: &Path,
entries: &mut Vec<(PathBuf, String)>,
matcher: &Override,
) -> Result<bool> {
let mut found_any = false;
for entry in WalkDir::new(dir).follow_links(true).into_iter() {
let Some(entry) = prune_symlink_walk_error(entry)? else {
continue;
};
let path = entry.path();
if path == dir {
continue; }
if !is_output(matcher, path, entry.file_type().is_dir()) {
continue;
}
if entry.file_type().is_file() {
entries.push(hash_file(path)?);
found_any = true;
} else if entry.file_type().is_dir() {
entries.push((path.to_path_buf(), "dir".to_string()));
found_any = true;
}
}
Ok(found_any)
}
for path_str in static_paths {
let path = {
let p = Path::new(path_str.as_str());
if p.is_relative() {
root.join(p)
} else {
p.to_path_buf()
}
};
match path.metadata() {
Ok(m) if m.is_file() => {
if is_output(&matcher, &path, false) {
entries.push(hash_file(&path)?);
} else {
continue;
}
}
Ok(m) if m.is_dir() => {
if !push_dir_entries(&path, &mut entries, &matcher)?
&& is_output(&matcher, &path, true)
{
entries.push((path, "empty-dir".to_string()));
}
}
Ok(_) => {
if is_output(&matcher, &path, false) {
entries.push((path, "other".to_string()));
}
}
Err(_) => {
if is_output(&matcher, &path, false) || is_output(&matcher, &path, true) {
return Ok(None); }
}
}
}
for pattern_str in glob_pats {
let mut glob_matched = false;
for expanded in expand_enumeration_patterns(pattern_str)? {
let full = resolve_task_path(root, expanded);
for entry in glob_walk(&full, false)? {
let Some(entry) = prune_symlink_walk_error(entry)? else {
continue;
};
let path = entry.into_path();
glob_matched = true;
let metadata = match path.metadata() {
Ok(metadata) => metadata,
Err(_) => {
if !is_output(&matcher, &path, false) && !is_output(&matcher, &path, true) {
continue;
}
return Ok(None); }
};
if !is_output(&matcher, &path, metadata.is_dir()) {
continue;
}
match metadata {
m if m.is_file() => {
entries.push(hash_file(&path)?);
}
m if m.is_dir() => {
let found = push_dir_entries(&path, &mut entries, &matcher)?;
if !found {
entries.push((path, "empty-dir".to_string()));
}
}
_ => {
entries.push((path, "other".to_string()));
}
}
}
}
if !glob_matched {
return Ok(None);
}
}
entries.sort_by(|(a, _), (b, _)| a.cmp(b));
Ok(Some(hash::hash_to_str(&entries)))
}
fn get_file_metadatas(
root: &Path,
patterns_or_paths: &[String],
matcher: &Override,
) -> Result<Vec<(PathBuf, fs::Metadata)>> {
if patterns_or_paths.is_empty() {
return Ok(vec![]);
}
let (patterns, paths): (Vec<&String>, Vec<&String>) =
patterns_or_paths.iter().partition(|p| is_glob_pattern(p));
let mut metadatas = BTreeMap::new();
for pattern in patterns {
for expanded in expand_enumeration_patterns(pattern)? {
let pattern = resolve_task_path(root, expanded);
let files = glob_walk(&pattern, false)?;
for file in files.flatten().map(|entry| entry.into_path()) {
if let Ok(metadata) = file.metadata() {
metadatas.insert(file, metadata);
}
}
}
}
for path in paths {
let file = resolve_task_path(root, path);
if let Ok(metadata) = file.metadata() {
metadatas.insert(file, metadata);
}
}
let metadatas = metadatas
.into_iter()
.filter(|(_, m)| m.is_file())
.filter(|(p, _)| is_source(matcher, p))
.collect_vec();
Ok(metadatas)
}
fn file_metadatas_to_hash(metadatas: &[(PathBuf, fs::Metadata)]) -> String {
let stat_info: Vec<_> = metadatas
.iter()
.map(|(p, m)| (p, m.len(), m.modified().ok()))
.collect();
hash::hash_to_str(&stat_info)
}
#[derive(Debug, Serialize, Deserialize)]
struct CachedFileHash {
mtime_secs: i64,
mtime_nanos: u32,
size: u64,
hash: String,
}
type ContentHashCache = BTreeMap<PathBuf, CachedFileHash>;
fn content_hash_cache_path(task: &Task, root: &Path) -> PathBuf {
dirs::STATE
.join("task-sources")
.join(format!("{}-content-cache", task_state_key(task, root)))
}
fn load_content_hash_cache(path: &Path) -> ContentHashCache {
(|| -> Result<ContentHashCache> {
let mut zlib = ZlibDecoder::new(File::open(path)?);
let mut bytes = Vec::new();
zlib.read_to_end(&mut bytes)?;
Ok(rmp_serde::from_slice(&bytes)?)
})()
.unwrap_or_default()
}
fn save_content_hash_cache(path: &Path, cache: &ContentHashCache) -> Result<()> {
if let Some(parent) = path.parent() {
file::create_dir_all(parent)?;
}
let partial = path.with_extension(format!("part-{}", random_string(8)));
{
let mut zlib = ZlibEncoder::new(File::create(&partial)?, Compression::fast());
zlib.write_all(&rmp_serde::to_vec_named(cache)?)?;
zlib.finish()?;
}
file::rename(&partial, path)?;
Ok(())
}
fn cached_entry_matches(entry: &CachedFileHash, metadata: &fs::Metadata) -> bool {
let Ok(mtime) = metadata.modified() else {
return false;
};
let Ok(dur) = mtime.duration_since(UNIX_EPOCH) else {
return false;
};
entry.size == metadata.len()
&& entry.mtime_secs == dur.as_secs() as i64
&& entry.mtime_nanos == dur.subsec_nanos()
}
fn make_cache_entry(metadata: &fs::Metadata, hash: String) -> CachedFileHash {
let dur = metadata
.modified()
.ok()
.and_then(|m| m.duration_since(UNIX_EPOCH).ok());
CachedFileHash {
mtime_secs: dur.map(|d| d.as_secs() as i64).unwrap_or(0),
mtime_nanos: dur.map(|d| d.subsec_nanos()).unwrap_or(0),
size: metadata.len(),
hash,
}
}
fn file_contents_to_hash(
metadatas: &[(PathBuf, fs::Metadata)],
cache: &mut ContentHashCache,
) -> Result<String> {
let mut content_hashes: Vec<(&PathBuf, String)> = Vec::new();
let mut next: ContentHashCache = BTreeMap::new();
for (path, metadata) in metadatas {
let hash = match cache.get(path) {
Some(entry) if cached_entry_matches(entry, metadata) => entry.hash.clone(),
_ => hash::file_hash_blake3(path, None)?,
};
next.insert(path.clone(), make_cache_entry(metadata, hash.clone()));
content_hashes.push((path, hash));
}
*cache = next;
Ok(hash::hash_to_str(&content_hashes))
}
fn get_last_modified_from_metadatas(metadatas: &[(PathBuf, fs::Metadata)]) -> Option<SystemTime> {
metadatas.iter().flat_map(|(_, m)| m.modified()).max()
}
fn get_last_modified(root: &Path, patterns_or_paths: &[String]) -> Result<Option<SystemTime>> {
if patterns_or_paths.is_empty() {
return Ok(None);
}
let matcher = build_output_matcher(root, patterns_or_paths)?;
let mut file_modified = Vec::new();
let mut directory_modified = Vec::new();
for pattern in output_glob_patterns(patterns_or_paths) {
let is_glob = is_glob_pattern(&pattern);
let candidates = if is_glob {
let mut candidates = Vec::new();
for expanded in expand_enumeration_patterns(&pattern)? {
let expanded = resolve_task_path(root, expanded);
for entry in glob_walk(&expanded, false)? {
if let Some(entry) = prune_symlink_walk_error(entry)? {
candidates.push(entry.into_path());
}
}
}
candidates
} else {
vec![resolve_task_path(root, &pattern)]
};
let mut found_candidate = false;
for candidate in candidates {
if fs::symlink_metadata(&candidate).is_err() {
continue;
}
found_candidate = true;
for entry in WalkDir::new(candidate).follow_links(true) {
let Some(entry) = prune_symlink_walk_error(entry)? else {
continue;
};
let metadata = entry.metadata()?;
if is_output(&matcher, entry.path(), metadata.is_dir()) {
if metadata.is_dir() {
directory_modified.push(metadata.modified()?);
} else {
file_modified.push(metadata.modified()?);
}
}
}
}
if !found_candidate
&& (is_glob || {
let path = resolve_task_path(root, &pattern);
is_output(&matcher, &path, false) || is_output(&matcher, &path, true)
})
{
return Ok(None);
}
}
let last_mod = file_modified.into_iter().chain(directory_modified).max();
trace!(
"last_modified of {}: {last_mod:?}",
patterns_or_paths.iter().join(" ")
);
Ok(last_mod)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn glob_walk_skips_symlink_loops() -> Result<()> {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir()?;
let tree = temp.path().join("tree");
fs::create_dir(&tree)?;
fs::write(tree.join("input.txt"), "input")?;
symlink(".", tree.join("a"))?;
symlink(".", tree.join("b"))?;
let pattern = tree.join("**/*");
let entries = glob_walk(&pattern, false)?.collect_vec();
let paths = entries
.iter()
.filter_map(|entry| entry.as_ref().ok())
.map(|entry| entry.path())
.collect_vec();
assert!(paths.contains(&tree.join("input.txt").as_path()));
assert_eq!(entries.iter().filter(|entry| entry.is_err()).count(), 2);
assert!(
entries.len() < 10,
"loop expansion was not bounded: {entries:?}"
);
Ok(())
}
#[cfg(unix)]
#[test]
fn glob_walk_skips_broken_symlinks() -> Result<()> {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir()?;
let tree = temp.path().join("tree");
fs::create_dir(&tree)?;
fs::write(tree.join("input.txt"), "input")?;
symlink("missing", tree.join("dangling"))?;
symlink("self", tree.join("self"))?;
let paths = glob_walk(&tree.join("**/*"), false)?
.filter_map(|entry| prune_symlink_walk_error(entry).transpose())
.map(|entry| entry.map(|entry| entry.into_path()))
.collect::<Result<Vec<_>>>()?;
assert_eq!(paths, [tree.join("input.txt")]);
Ok(())
}
#[cfg(unix)]
#[test]
fn glob_walk_follows_non_looping_directory_symlinks() -> Result<()> {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir()?;
let tree = temp.path().join("tree");
let actual = temp.path().join("actual");
fs::create_dir(&tree)?;
fs::create_dir(&actual)?;
fs::write(actual.join("input.txt"), "input")?;
symlink("../actual", tree.join("linked"))?;
let paths = glob_walk(&tree.join("**/*"), false)?
.map(|entry| entry.map(|entry| entry.into_path()))
.collect::<Result<Vec<_>, _>>()?;
assert!(paths.contains(&tree.join("linked/input.txt")));
Ok(())
}
#[test]
fn glob_walk_preserves_non_recursive_depth_and_order() -> Result<()> {
let temp = tempfile::tempdir()?;
let tree = temp.path().join("tree");
fs::create_dir_all(tree.join("nested"))?;
fs::write(tree.join("b.txt"), "b")?;
fs::write(tree.join("a.txt"), "a")?;
fs::write(tree.join("nested/deep.txt"), "deep")?;
let paths = glob_walk(&tree.join("*.txt"), false)?
.map(|entry| entry.map(|entry| entry.into_path()))
.collect::<Result<Vec<_>, _>>()?;
assert_eq!(paths, [tree.join("a.txt"), tree.join("b.txt")]);
Ok(())
}
#[test]
fn glob_walk_treats_missing_literal_prefix_as_no_matches() -> Result<()> {
let temp = tempfile::tempdir()?;
let entries = glob_walk(&temp.path().join("missing/**/*.txt"), false)?.collect_vec();
assert!(entries.is_empty());
Ok(())
}
fn matches(sources: &[&str], path: &str) -> bool {
let sources: Vec<String> = sources.iter().map(|s| s.to_string()).collect();
let root = Path::new(".");
let matcher = build_source_matcher(root, root, &sources);
is_source(&matcher, Path::new(path))
}
#[test]
fn patterns_stay_slash_separated_on_every_platform() {
let joined = Path::new("dist").join("**").join("*.map");
assert_eq!(pattern_from_path(&joined).unwrap(), "dist/**/*.map");
let root = Path::new("/project");
let normalized = normalize_pattern(root, root, "!dist/**/*.map");
assert_eq!(normalized, "!dist/**/*.map");
}
#[test]
fn output_matcher_excludes_and_reincludes_descendants() {
let root = Path::new("/project");
let patterns = vec![
"dist".to_string(),
"!dist/**/*.map".to_string(),
"dist/keep.map".to_string(),
];
let matcher = build_output_matcher(root, &patterns).unwrap();
assert!(is_output(&matcher, &root.join("dist/app.js"), false));
assert!(!is_output(&matcher, &root.join("dist/app.map"), false));
assert!(is_output(&matcher, &root.join("dist/nested/app.js"), false));
assert!(is_output(&matcher, &root.join("dist/keep.map"), false));
}
#[test]
fn output_matcher_normalizes_absolute_patterns_under_root() {
let root = tempfile::tempdir().unwrap();
let output = root.path().join("dist/result.txt");
let patterns = vec![format!("{}/dist/**/*", root.path().display())];
let matcher = build_output_matcher(root.path(), &patterns).unwrap();
assert!(is_output(&matcher, &output, false));
}
#[test]
fn output_matcher_normalizes_parent_traversal() {
let root = tempfile::tempdir().unwrap();
let patterns = vec!["dist/../out/result.txt".to_string()];
let matcher = build_output_matcher(root.path(), &patterns).unwrap();
assert!(is_output(
&matcher,
&root.path().join("dist/../out/result.txt"),
false
));
assert!(is_output(
&matcher,
&root.path().join("out/result.txt"),
false
));
}
#[test]
fn metadata_hash_notices_a_same_size_change_with_an_older_mtime() {
let root = tempfile::tempdir().unwrap();
let p = root.path().join("pin.txt");
fs::write(&p, "1.2.3").unwrap();
let before = file_metadatas_to_hash(&[(p.clone(), fs::metadata(&p).unwrap())]);
fs::write(&p, "1.2.4").unwrap();
let restored = filetime::FileTime::from_unix_time(1_000_000, 0);
filetime::set_file_times(&p, restored, restored).unwrap();
let after = file_metadatas_to_hash(&[(p.clone(), fs::metadata(&p).unwrap())]);
assert_eq!(
fs::metadata(&p).unwrap().len(),
5,
"the fixture only exercises the bug while both versions are the same size"
);
assert_ne!(before, after, "the mtime change should be part of the hash");
}
#[test]
fn metadata_hash_separates_pre_epoch_mtimes() {
let root = tempfile::tempdir().unwrap();
let p = root.path().join("ancient.txt");
fs::write(&p, "x").unwrap();
let stamp = |secs: i64| {
let t = filetime::FileTime::from_unix_time(secs, 0);
filetime::set_file_times(&p, t, t).ok()?;
let metadata = fs::metadata(&p).unwrap();
let mtime = metadata.modified().ok()?;
Some((mtime, file_metadatas_to_hash(&[(p.clone(), metadata)])))
};
let (Some((first_mtime, first)), Some((second_mtime, second))) =
(stamp(-2_000_000), stamp(-1_000_000))
else {
return;
};
if first_mtime == second_mtime {
return;
}
assert_ne!(
first, second,
"two different pre-epoch mtimes should hash differently"
);
}
#[test]
fn output_globs_ignore_excludes_and_unescape_literal_bangs() {
assert_eq!(
output_glob_patterns(&[
"dist".to_string(),
"!dist/**/*.map".to_string(),
"\\!important".to_string(),
]),
["dist", "!important"]
);
}
#[test]
fn trailing_globstar_expands_to_reach_files() {
assert_eq!(expand_trailing_globstar("src/**"), "src/**/*");
assert_eq!(expand_trailing_globstar("**"), "**/*");
assert_eq!(expand_enumeration_patterns("src/**").unwrap(), ["src/**/*"]);
}
#[test]
fn trailing_globstar_expands_inside_brace_alternatives() {
assert_eq!(
expand_enumeration_patterns("{src,dist}/**").unwrap(),
["src/**/*", "dist/**/*"]
);
assert_eq!(
expand_enumeration_patterns("{src/**,dist/**,docs/*.md}").unwrap(),
["src/**/*", "dist/**/*", "docs/*.md"]
);
}
#[test]
fn trailing_globstar_follows_platform_separators() {
if cfg!(windows) {
assert_eq!(expand_trailing_globstar(r"src\**"), r"src\**/*");
} else {
assert_eq!(expand_trailing_globstar(r"src\**"), r"src\**");
}
}
#[test]
fn interior_globstar_and_other_patterns_are_untouched() {
for pattern in [
"src/**/foo.rs",
"src/**/*.ts",
"src/*",
"dist",
"src/**/*",
"**/*",
"a**",
] {
assert_eq!(expand_trailing_globstar(pattern), pattern);
}
}
#[test]
fn glob_braces_expand_nested_and_multiple_alternates() {
assert_eq!(
expand_glob_braces("src/{a,{b,c}}/{one,two}.txt").unwrap(),
[
"src/a/one.txt",
"src/a/two.txt",
"src/b/one.txt",
"src/b/two.txt",
"src/c/one.txt",
"src/c/two.txt",
]
);
assert_eq!(expand_glob_braces("{,a}.txt").unwrap(), ["a.txt"]);
assert!(expand_glob_braces("src/{a,b.txt").is_err());
assert!(expand_glob_braces(&"{a,b}".repeat(11)).is_err());
}
#[test]
fn glob_braces_preserve_literal_singleton_groups() {
assert_eq!(
expand_glob_braces("{generated}.txt").unwrap(),
["[{]generated[}].txt"]
);
assert_eq!(
expand_glob_braces("{generated}/{a,b}.txt").unwrap(),
["[{]generated[}]/a.txt", "[{]generated[}]/b.txt"]
);
assert_eq!(
expand_glob_braces("{generated}.{txt,out}").unwrap(),
["[{]generated[}].txt", "[{]generated[}].out"]
);
assert_eq!(
expand_glob_braces("{prefix-{a,b}}.txt").unwrap(),
["[{]prefix-a[}].txt", "[{]prefix-b[}].txt"]
);
}
#[cfg(not(windows))]
#[test]
fn glob_braces_preserve_escaped_unix_braces() {
assert_eq!(
expand_glob_braces(r"src/\{literal\}/[{}].txt").unwrap(),
[r"src/\{literal\}/[{}].txt"]
);
}
#[cfg(windows)]
#[test]
fn glob_braces_treat_windows_backslashes_as_separators() {
assert_eq!(
expand_glob_braces(r"C:\build\{debug,release}\*.exe").unwrap(),
[r"C:\build\debug\*.exe", r"C:\build\release\*.exe"]
);
}
#[test]
fn source_and_output_matchers_support_ordered_brace_globs() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let source_matcher = build_source_matcher(
root,
root,
&[
"{Cargo.toml,README.md}".to_string(),
"!README.md".to_string(),
"README.md".to_string(),
],
);
let output_matcher = build_output_matcher(
root,
&[
"{Cargo.toml,README.md}".to_string(),
"!README.md".to_string(),
],
)
.unwrap();
assert!(is_source(&source_matcher, &root.join("Cargo.toml")));
assert!(is_source(&source_matcher, &root.join("README.md")));
assert!(is_output(&output_matcher, &root.join("Cargo.toml"), false));
assert!(!is_output(&output_matcher, &root.join("README.md"), false));
}
#[test]
fn output_hash_supports_brace_globs() {
let root = tempfile::tempdir().unwrap();
fs::write(root.path().join("a.out"), "a").unwrap();
fs::write(root.path().join("b.out"), "b").unwrap();
let task = Task {
outputs: crate::task::task_sources::TaskOutputs::Files(vec!["{a,b}.out".to_string()]),
..Default::default()
};
assert!(compute_output_hash(&task, root.path()).unwrap().is_some());
}
#[test]
fn output_mtime_includes_selected_directories() {
let root = tempfile::tempdir().unwrap();
let dist = root.path().join("dist");
let output = dist.join("result.txt");
fs::create_dir(&dist).unwrap();
fs::write(&output, "result").unwrap();
let file_mtime = filetime::FileTime::from_unix_time(100, 0);
let directory_mtime = filetime::FileTime::from_unix_time(200, 0);
filetime::set_file_mtime(&output, file_mtime).unwrap();
filetime::set_file_mtime(&dist, directory_mtime).unwrap();
let modified = get_last_modified(root.path(), &["dist".to_string()])
.unwrap()
.unwrap();
assert_eq!(modified, SystemTime::from(directory_mtime));
}
#[test]
fn output_mtime_requires_all_selected_static_paths() {
let root = tempfile::tempdir().unwrap();
fs::write(root.path().join("present.txt"), "present").unwrap();
let modified = get_last_modified(
root.path(),
&["present.txt".to_string(), "missing.txt".to_string()],
)
.unwrap();
assert!(modified.is_none());
}
#[test]
fn output_mtime_requires_each_positive_glob_to_match() {
let root = tempfile::tempdir().unwrap();
fs::write(root.path().join("present.txt"), "present").unwrap();
let modified = get_last_modified(
root.path(),
&["present.txt".to_string(), "*.generated".to_string()],
)
.unwrap();
assert!(modified.is_none());
}
#[test]
fn output_mtime_allows_missing_excluded_static_paths() {
let root = tempfile::tempdir().unwrap();
fs::write(root.path().join("present.txt"), "present").unwrap();
let modified = get_last_modified(
root.path(),
&[
"present.txt".to_string(),
"missing.txt".to_string(),
"!missing.txt".to_string(),
],
)
.unwrap();
assert!(modified.is_some());
}
#[test]
fn output_mtime_brace_alternatives_require_any_match() {
let root = tempfile::tempdir().unwrap();
fs::write(root.path().join("a.out"), "a").unwrap();
let modified = get_last_modified(root.path(), &["{a,b}.out".to_string()]).unwrap();
assert!(modified.is_some());
}
#[test]
fn output_mtime_allows_glob_matches_that_are_all_excluded() {
let root = tempfile::tempdir().unwrap();
fs::write(root.path().join("present.txt"), "present").unwrap();
fs::create_dir(root.path().join("dist")).unwrap();
fs::write(root.path().join("dist/vendor.js"), "vendor").unwrap();
let modified = get_last_modified(
root.path(),
&[
"present.txt".to_string(),
"dist/*.js".to_string(),
"!dist/vendor.js".to_string(),
],
)
.unwrap();
assert!(modified.is_some());
}
#[test]
fn output_hash_allows_missing_excluded_static_paths() {
let root = tempfile::tempdir().unwrap();
let task = Task {
outputs: crate::task::task_sources::TaskOutputs::Files(vec![
"missing.txt".to_string(),
"!missing.txt".to_string(),
]),
..Default::default()
};
assert!(compute_output_hash(&task, root.path()).unwrap().is_some());
}
#[test]
fn output_hash_allows_glob_matches_that_are_all_excluded() {
let root = tempfile::tempdir().unwrap();
fs::create_dir(root.path().join("dist")).unwrap();
fs::write(root.path().join("dist/vendor.js"), "vendor").unwrap();
let task = Task {
outputs: crate::task::task_sources::TaskOutputs::Files(vec![
"dist/*.js".to_string(),
"!dist/vendor.js".to_string(),
]),
..Default::default()
};
assert!(compute_output_hash(&task, root.path()).unwrap().is_some());
}
#[test]
fn task_state_key_includes_all_definition_sources() {
let root = Path::new("/project");
let mut task = Task {
name: "build".to_string(),
config_source: PathBuf::from(".mise/tasks/build"),
..Default::default()
};
let primary_key = task_state_key(&task, root);
task.additional_config_sources
.push(PathBuf::from("mise.toml"));
assert_ne!(primary_key, task_state_key(&task, root));
}
#[test]
fn task_state_key_changes_when_run_changes() {
use crate::task::RunEntry;
let root = Path::new("/project");
let mut task = Task {
name: "build".to_string(),
config_source: PathBuf::from("mise.toml"),
run: vec![RunEntry::Script("echo v1".to_string())],
..Default::default()
};
let key_v1 = task_state_key(&task, root);
task.run = vec![RunEntry::Script("echo v2".to_string())];
assert_ne!(key_v1, task_state_key(&task, root));
}
#[test]
fn task_state_key_changes_when_sources_change() {
let root = Path::new("/project");
let mut task = Task {
name: "build".to_string(),
config_source: PathBuf::from("mise.toml"),
sources: vec!["src.txt".to_string()],
..Default::default()
};
let key_v1 = task_state_key(&task, root);
task.sources = vec!["other.txt".to_string()];
assert_ne!(key_v1, task_state_key(&task, root));
}
#[test]
fn glob_patterns_drops_excludes_and_unescapes() {
let inputs = vec![
"src/**/*.ts".to_string(),
"!src/**/*.test.ts".to_string(),
"\\!literal.txt".to_string(),
"tsconfig.json".to_string(),
];
assert_eq!(
source_glob_patterns(&inputs),
vec!["src/**/*.ts", "!literal.txt", "tsconfig.json"],
);
}
#[test]
fn matcher_includes_plain_pattern() {
assert!(matches(&["src/**/*.ts"], "src/foo.ts"));
assert!(matches(&["src/**/*.ts"], "src/sub/foo.ts"));
assert!(!matches(&["src/**/*.ts"], "lib/foo.ts"));
}
#[test]
fn matcher_negation_excludes() {
let pats = &["src/**/*.ts", "!src/**/*.test.ts"];
assert!(matches(pats, "src/foo.ts"));
assert!(!matches(pats, "src/foo.test.ts"));
}
#[test]
fn matcher_reincludes_after_negation() {
let pats = &["src/**/*.ts", "!src/**/*.test.ts", "src/keep.test.ts"];
assert!(matches(pats, "src/foo.ts"));
assert!(!matches(pats, "src/foo.test.ts"));
assert!(matches(pats, "src/keep.test.ts"));
}
#[test]
fn matcher_escaped_literal_bang() {
let pats = &["\\!important.txt", "!ignored.txt"];
assert!(matches(pats, "!important.txt"));
assert!(!matches(pats, "ignored.txt"));
}
#[test]
#[cfg(unix)]
fn matcher_absolute_literal_bang_under_root() {
let root = Path::new("/project");
let sources = vec!["/project/!important.txt".to_string()];
let matcher = build_source_matcher(root, root, &sources);
assert!(is_source(&matcher, Path::new("/project/!important.txt")));
assert!(!is_source(&matcher, Path::new("/project/other.txt")));
}
#[test]
#[cfg(unix)]
fn matcher_absolute_pattern_under_root() {
let root = Path::new("/proj");
let sources = vec!["/proj/input".to_string()];
let matcher = build_source_matcher(root, root, &sources);
assert!(is_source(&matcher, Path::new("/proj/input")));
assert!(!is_source(&matcher, Path::new("/proj/other")));
}
#[test]
#[cfg(unix)]
fn matcher_absolute_negation_under_root() {
let root = Path::new("/proj");
let sources = vec![
"/proj/src/**/*.ts".to_string(),
"!/proj/src/**/*.test.ts".to_string(),
];
let matcher = build_source_matcher(root, root, &sources);
assert!(is_source(&matcher, Path::new("/proj/src/foo.ts")));
assert!(!is_source(&matcher, Path::new("/proj/src/foo.test.ts")));
}
#[test]
#[cfg(unix)]
fn matcher_absolute_path_outside_root_passes_through() {
let root = Path::new("/proj");
let sources = vec!["/elsewhere/Cargo.toml".to_string()];
let matcher = build_source_matcher(root, root, &sources);
assert!(is_source(&matcher, Path::new("/elsewhere/Cargo.toml")));
}
#[test]
#[cfg(unix)]
fn matcher_subproject_absolute_workspace_pattern() {
let match_root = Path::new("/workspace");
let task_cwd = Path::new("/workspace/lib/worker");
let sources = vec!["/workspace/lib/**/*".to_string()];
let matcher = build_source_matcher(match_root, task_cwd, &sources);
assert!(is_source(
&matcher,
Path::new("/workspace/lib/worker/worker.go")
));
assert!(is_source(&matcher, Path::new("/workspace/lib/shared.go")));
assert!(!is_source(&matcher, Path::new("/workspace/other/file.go")));
}
#[test]
fn absolute_source_patterns_are_enumerated_from_a_subproject() -> Result<()> {
let temp = tempfile::tempdir()?;
let workspace = temp.path();
let task_cwd = workspace.join("packages/app");
let source = task_cwd.join("src/input.txt");
let global = workspace.join("workspace.txt");
fs::create_dir_all(source.parent().unwrap())?;
fs::write(&source, "source")?;
fs::write(&global, "global")?;
let sources = vec![
format!("{}/packages/app/src/**/*", workspace.display()),
global.to_string_lossy().to_string(),
];
let matcher = build_source_matcher(workspace, &task_cwd, &sources);
let metadatas = get_file_metadatas(&task_cwd, &source_glob_patterns(&sources), &matcher)?;
let paths = metadatas
.into_iter()
.map(|(path, _)| path)
.collect::<Vec<_>>();
assert!(paths.contains(&source), "{paths:?}");
assert!(paths.contains(&global), "{paths:?}");
Ok(())
}
#[test]
#[cfg(unix)]
fn matcher_subproject_relative_pattern() {
let match_root = Path::new("/workspace");
let task_cwd = Path::new("/workspace/lib/worker");
let sources = vec!["src/**/*.go".to_string()];
let matcher = build_source_matcher(match_root, task_cwd, &sources);
assert!(is_source(
&matcher,
Path::new("/workspace/lib/worker/src/main.go")
));
assert!(!is_source(&matcher, Path::new("/workspace/src/other.go")));
}
#[test]
#[cfg(unix)]
fn matcher_subproject_parent_relative_pattern() {
let match_root = Path::new("/workspace");
let task_cwd = Path::new("/workspace/lib/worker");
let sources = vec!["../shared/**/*.go".to_string()];
let matcher = build_source_matcher(match_root, task_cwd, &sources);
assert!(is_source(
&matcher,
Path::new("/workspace/lib/shared/util.go")
));
assert!(!is_source(&matcher, Path::new("/workspace/other.go")));
}
#[test]
#[cfg(unix)]
fn matcher_accepts_enumerated_paths_containing_parent_dirs() {
let match_root = Path::new("/workspace");
let task_cwd = Path::new("/workspace/lib/worker");
let sources = vec!["../shared/**/*.go".to_string()];
let matcher = build_source_matcher(match_root, task_cwd, &sources);
assert!(is_source(
&matcher,
Path::new("/workspace/lib/worker/../shared/util.go")
));
}
#[test]
#[cfg(unix)]
fn matcher_parent_pattern_above_match_root_passes_through() {
let match_root = Path::new("/workspace");
let task_cwd = Path::new("/workspace/lib");
let sources = vec!["../../outside/**".to_string()];
let matcher = build_source_matcher(match_root, task_cwd, &sources);
assert!(is_source(&matcher, Path::new("/outside/x")));
}
#[test]
#[cfg(unix)]
fn matcher_leaves_parent_dirs_that_would_collapse_a_glob() {
let root = Path::new("/workspace");
let sources = vec!["**/../x".to_string()];
let matcher = build_source_matcher(root, root, &sources);
assert!(!is_source(&matcher, Path::new("/workspace/x")));
}
#[test]
#[cfg(unix)]
fn matcher_parent_relative_pattern_from_a_task_dir_containing_a_glob_char() {
let match_root = Path::new("/workspace");
let task_cwd = Path::new("/workspace/pkg*");
let sources = vec!["../shared/**".to_string()];
let matcher = build_source_matcher(match_root, task_cwd, &sources);
assert!(is_source(&matcher, Path::new("/workspace/shared/util.go")));
}
#[test]
#[cfg(unix)]
fn matcher_normalizes_absolute_pattern_with_parent_dirs() {
let root = Path::new("/workspace");
let sources = vec!["/workspace/lib/../shared/x".to_string()];
let matcher = build_source_matcher(root, root, &sources);
assert!(is_source(&matcher, Path::new("/workspace/shared/x")));
}
#[test]
fn lexical_normalize_resolves_dot_segments() {
assert_eq!(lexical_normalize(Path::new("../x")), PathBuf::from("../x"));
assert_eq!(
lexical_normalize(Path::new("a/b/../c")),
PathBuf::from("a/c")
);
assert_eq!(lexical_normalize(Path::new("./a")), PathBuf::from("a"));
}
#[test]
#[cfg(unix)]
fn lexical_normalize_stops_climbing_at_the_root() {
assert_eq!(lexical_normalize(Path::new("/a/../..")), PathBuf::from("/"));
}
#[test]
fn relative_sources_match_when_task_dir_starts_with_dot() -> Result<()> {
let temp = tempfile::tempdir()?;
let workspace = temp.path();
let task_cwd = normalize_task_cwd(workspace.join("./sub"));
let source = workspace.join("sub/input.txt");
fs::create_dir_all(source.parent().unwrap())?;
fs::write(&source, "source")?;
let sources = vec!["input.txt".to_string()];
let matcher = build_source_matcher(workspace, &task_cwd, &sources);
let metadatas = get_file_metadatas(&task_cwd, &sources, &matcher)?;
assert_eq!(
metadatas.into_iter().map(|(path, _)| path).collect_vec(),
[source]
);
Ok(())
}
#[test]
fn content_hash_cache_reuses_unchanged_files() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("a.txt");
let b = tmp.path().join("b.txt");
std::fs::write(&a, "hello").unwrap();
std::fs::write(&b, "world").unwrap();
let metadatas = vec![
(a.clone(), a.metadata().unwrap()),
(b.clone(), b.metadata().unwrap()),
];
let mut cache = ContentHashCache::new();
let first = file_contents_to_hash(&metadatas, &mut cache).unwrap();
assert_eq!(cache.len(), 2);
let a_hash_v1 = cache.get(&a).unwrap().hash.clone();
let second = file_contents_to_hash(&metadatas, &mut cache).unwrap();
assert_eq!(first, second);
assert_eq!(cache.get(&a).unwrap().hash, a_hash_v1);
std::fs::write(&a, "hello world").unwrap();
let metadatas = vec![
(a.clone(), a.metadata().unwrap()),
(b.clone(), b.metadata().unwrap()),
];
let third = file_contents_to_hash(&metadatas, &mut cache).unwrap();
assert_ne!(second, third);
assert_ne!(cache.get(&a).unwrap().hash, a_hash_v1);
}
#[test]
fn content_hash_cache_prunes_dropped_files() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("a.txt");
let b = tmp.path().join("b.txt");
std::fs::write(&a, "hello").unwrap();
std::fs::write(&b, "world").unwrap();
let mut cache = ContentHashCache::new();
let metadatas = vec![
(a.clone(), a.metadata().unwrap()),
(b.clone(), b.metadata().unwrap()),
];
file_contents_to_hash(&metadatas, &mut cache).unwrap();
assert_eq!(cache.len(), 2);
let metadatas = vec![(a.clone(), a.metadata().unwrap())];
file_contents_to_hash(&metadatas, &mut cache).unwrap();
assert_eq!(cache.len(), 1);
assert!(cache.contains_key(&a));
assert!(!cache.contains_key(&b));
}
#[test]
fn content_hash_cache_round_trips_through_disk() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("a.txt");
std::fs::write(&a, "hello").unwrap();
let mut cache = ContentHashCache::new();
let metadatas = vec![(a.clone(), a.metadata().unwrap())];
file_contents_to_hash(&metadatas, &mut cache).unwrap();
let cache_path = tmp.path().join("cache.bin");
save_content_hash_cache(&cache_path, &cache).unwrap();
let loaded = load_content_hash_cache(&cache_path);
assert_eq!(loaded.len(), 1);
assert_eq!(loaded.get(&a).unwrap().hash, cache.get(&a).unwrap().hash,);
std::fs::write(&cache_path, b"not a valid msgpack stream").unwrap();
let loaded = load_content_hash_cache(&cache_path);
assert!(loaded.is_empty());
}
}