use super::p4ignore::{P4Environment, P4Matcher};
use crate::cancellation::AgentCancellation;
use ignore::WalkBuilder;
use std::{
collections::HashSet,
fs,
path::{Path, PathBuf},
time::Instant,
};
#[derive(Debug, Default)]
pub(super) struct WorkspaceWalker {
#[cfg(test)]
forced_diagnostics: WorkspaceWalkDiagnostics,
}
#[cfg(test)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(super) struct WorkspaceWalkDiagnostics {
pub(super) walk_errors: usize,
pub(super) entries_omitted: usize,
}
#[derive(Debug, Clone, Copy, Default)]
pub(super) struct WorkspaceVisitStatus {
pub(super) completed: bool,
pub(super) walk_errors: usize,
pub(super) entries_omitted: usize,
}
fn classify_walk_entry(
entry: Result<Option<fs::FileType>, ()>,
status: &mut WorkspaceVisitStatus,
) -> Option<fs::FileType> {
match entry {
Ok(Some(file_type)) => Some(file_type),
Ok(None) => {
status.completed = false;
status.entries_omitted = status.entries_omitted.saturating_add(1);
None
}
Err(()) => {
status.completed = false;
status.walk_errors = status.walk_errors.saturating_add(1);
status.entries_omitted = status.entries_omitted.saturating_add(1);
None
}
}
}
pub(super) struct WorkspaceWalkOptions<'a> {
pub(super) root: &'a Path,
pub(super) skip_dirs: &'a [&'a str],
pub(super) cancel_interval: usize,
}
impl WorkspaceWalker {
#[cfg(test)]
pub(super) fn with_forced_diagnostics(walk_errors: usize, entries_omitted: usize) -> Self {
Self {
forced_diagnostics: WorkspaceWalkDiagnostics {
walk_errors,
entries_omitted,
},
}
}
pub(super) fn visit_entries<F>(
&self,
options: WorkspaceWalkOptions<'_>,
cancellation: Option<&AgentCancellation>,
visitor: F,
) -> anyhow::Result<()>
where
F: FnMut(&Path, fs::FileType) -> anyhow::Result<bool>,
{
let _ = self.visit_entries_with_environment(
options,
cancellation,
&P4Environment::process(),
false,
visitor,
)?;
Ok(())
}
pub(super) fn visit_sorted_files_with_diagnostics<F>(
&self,
options: WorkspaceWalkOptions<'_>,
cancellation: Option<&AgentCancellation>,
mut visitor: F,
) -> anyhow::Result<WorkspaceVisitStatus>
where
F: FnMut(PathBuf) -> anyhow::Result<bool>,
{
self.visit_entries_with_environment(
options,
cancellation,
&P4Environment::process(),
true,
|path, ty| {
if ty.is_file() {
visitor(path.to_path_buf())
} else {
Ok(true)
}
},
)
}
fn visit_entries_with_environment<F>(
&self,
options: WorkspaceWalkOptions<'_>,
cancellation: Option<&AgentCancellation>,
environment: &P4Environment,
sorted: bool,
visitor: F,
) -> anyhow::Result<WorkspaceVisitStatus>
where
F: FnMut(&Path, fs::FileType) -> anyhow::Result<bool>,
{
self.visit_entries_with_environment_and_deadline(
options,
cancellation,
environment,
sorted,
None,
visitor,
)
}
fn visit_entries_with_environment_and_deadline<F>(
&self,
options: WorkspaceWalkOptions<'_>,
cancellation: Option<&AgentCancellation>,
environment: &P4Environment,
sorted: bool,
deadline: Option<Instant>,
mut visitor: F,
) -> anyhow::Result<WorkspaceVisitStatus>
where
F: FnMut(&Path, fs::FileType) -> anyhow::Result<bool>,
{
if let Some(c) = cancellation {
c.check()?;
}
if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
return Ok(WorkspaceVisitStatus::default());
}
let (walker, p4_matcher) = configured_walk(&options, environment, sorted, deadline)?;
let mut status = WorkspaceVisitStatus {
completed: true,
..WorkspaceVisitStatus::default()
};
for (index, entry) in walker.enumerate() {
if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
status.completed = false;
break;
}
if index % options.cancel_interval.max(1) == 0
&& let Some(c) = cancellation
{
c.check()?;
}
let entry = match entry {
Ok(entry) => entry,
Err(_) => {
let _ = classify_walk_entry(Err(()), &mut status);
continue;
}
};
let Some(ty) = classify_walk_entry(Ok(entry.file_type()), &mut status) else {
continue;
};
let path = entry.path();
if path == options.root {
continue;
}
if p4_matcher.is_ignored(path, ty.is_dir()) {
continue;
}
if !visitor(path, ty)? {
status.completed = false;
break;
}
}
if let Some(c) = cancellation {
c.check()?;
}
status.completed &= deadline.is_none_or(|deadline| Instant::now() < deadline);
if let Some(error) = p4_matcher.take_error() {
return Err(error);
}
#[cfg(test)]
{
status.walk_errors = status
.walk_errors
.saturating_add(self.forced_diagnostics.walk_errors);
status.entries_omitted = status
.entries_omitted
.saturating_add(self.forced_diagnostics.entries_omitted);
if self.forced_diagnostics.walk_errors > 0
|| self.forced_diagnostics.entries_omitted > 0
{
status.completed = false;
}
}
Ok(status)
}
pub(super) fn visit_files_with_deadline<F>(
&self,
options: WorkspaceWalkOptions<'_>,
cancellation: Option<&AgentCancellation>,
deadline: Option<Instant>,
mut visitor: F,
) -> anyhow::Result<WorkspaceVisitStatus>
where
F: FnMut(PathBuf) -> anyhow::Result<bool>,
{
self.visit_entries_with_environment_and_deadline(
options,
cancellation,
&P4Environment::process(),
false,
deadline,
|path, ty| {
if ty.is_file() {
visitor(path.to_path_buf())
} else {
Ok(true)
}
},
)
}
}
fn configured_walk(
options: &WorkspaceWalkOptions<'_>,
environment: &P4Environment,
sorted: bool,
deadline: Option<Instant>,
) -> anyhow::Result<(ignore::Walk, P4Matcher)> {
let skip_dirs = options
.skip_dirs
.iter()
.map(|name| (*name).to_string())
.collect::<HashSet<_>>();
let p4_matcher = P4Matcher::from_root(options.root, environment)?;
let p4_filter = p4_matcher.clone();
let p4_root = options.root.to_path_buf();
let mut builder = WalkBuilder::new(options.root);
builder.standard_filters(true).current_dir(options.root);
if sorted {
builder.sort_by_file_path(|a, b| a.cmp(b));
}
builder.filter_entry(move |entry| {
if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
return true;
}
let is_dir = entry.file_type().is_some_and(|x| x.is_dir());
let name = entry.file_name().to_string_lossy();
!(is_dir
&& (skip_dirs.contains(name.as_ref())
|| (entry.path() != p4_root && p4_filter.should_prune(entry.path()))))
});
Ok((builder.build(), p4_matcher))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn names(paths: &[PathBuf]) -> Vec<String> {
let mut names = paths
.iter()
.map(|path| path.file_name().unwrap().to_string_lossy().into_owned())
.collect::<Vec<_>>();
names.sort();
names
}
#[test]
fn classify_walk_entry_records_walk_errors_and_missing_file_types() {
let mut walk_error = WorkspaceVisitStatus {
completed: true,
..WorkspaceVisitStatus::default()
};
assert!(classify_walk_entry(Err(()), &mut walk_error).is_none());
assert_eq!(walk_error.walk_errors, 1);
assert_eq!(walk_error.entries_omitted, 1);
assert!(!walk_error.completed);
let mut missing_file_type = WorkspaceVisitStatus {
completed: true,
..WorkspaceVisitStatus::default()
};
assert!(classify_walk_entry(Ok(None), &mut missing_file_type).is_none());
assert_eq!(missing_file_type.walk_errors, 0);
assert_eq!(missing_file_type.entries_omitted, 1);
assert!(!missing_file_type.completed);
}
#[test]
fn directory_only_walk_reports_expired_deadline() {
let temp = tempfile::TempDir::new().unwrap();
fs::create_dir_all(temp.path().join("nested/empty")).unwrap();
fs::create_dir_all(temp.path().join(".hidden/empty")).unwrap();
let walk = |deadline| {
WorkspaceWalker::default()
.visit_files_with_deadline(
WorkspaceWalkOptions {
root: temp.path(),
skip_dirs: &[],
cancel_interval: 32,
},
None,
deadline,
|_| panic!("directory-only tree must not visit files"),
)
.unwrap()
};
assert!(walk(None).completed);
let status = walk(Some(Instant::now()));
assert!(!status.completed);
assert_eq!(status.walk_errors, 0);
assert_eq!(status.entries_omitted, 0);
}
#[test]
fn visit_files_respects_gitignore() {
let temp = tempfile::TempDir::new().unwrap();
fs::create_dir(temp.path().join(".git")).unwrap();
fs::write(temp.path().join(".gitignore"), "ignored.txt\n").unwrap();
fs::write(temp.path().join("ignored.txt"), "hidden").unwrap();
fs::write(temp.path().join("visible.txt"), "shown").unwrap();
let mut paths = Vec::new();
WorkspaceWalker::default()
.visit_files_with_deadline(
WorkspaceWalkOptions {
root: temp.path(),
skip_dirs: &[],
cancel_interval: 32,
},
None,
None,
|path| {
paths.push(path);
Ok(true)
},
)
.unwrap();
let names = names(&paths);
assert!(names.contains(&"visible.txt".to_string()));
assert!(!names.contains(&"ignored.txt".to_string()));
}
#[test]
fn visit_files_includes_root_and_nested_agents_files() {
let temp = tempfile::TempDir::new().unwrap();
fs::write(temp.path().join("AGENTS.md"), "root").unwrap();
fs::create_dir(temp.path().join("nested")).unwrap();
fs::write(temp.path().join("nested/AGENTS.md"), "nested").unwrap();
let mut paths = Vec::new();
WorkspaceWalker::default()
.visit_files_with_deadline(
WorkspaceWalkOptions {
root: temp.path(),
skip_dirs: &[],
cancel_interval: 32,
},
None,
None,
|path| {
paths.push(path);
Ok(true)
},
)
.unwrap();
paths.sort();
assert_eq!(
paths,
vec![
temp.path().join("AGENTS.md"),
temp.path().join("nested/AGENTS.md"),
]
);
}
#[test]
fn configured_skip_dirs_are_excluded() {
let temp = tempfile::TempDir::new().unwrap();
fs::create_dir(temp.path().join("target")).unwrap();
fs::write(temp.path().join("target/hidden.txt"), "hidden").unwrap();
fs::create_dir(temp.path().join("node_modules")).unwrap();
fs::write(temp.path().join("node_modules/visible.txt"), "shown").unwrap();
let mut target_only = Vec::new();
WorkspaceWalker::default()
.visit_entries(
WorkspaceWalkOptions {
root: temp.path(),
skip_dirs: &["target"],
cancel_interval: 32,
},
None,
|path, _| {
target_only.push(path.to_path_buf());
Ok(true)
},
)
.unwrap();
let target_names = names(&target_only);
assert!(!target_names.contains(&"hidden.txt".to_string()));
assert!(target_names.contains(&"visible.txt".to_string()));
let mut both = Vec::new();
WorkspaceWalker::default()
.visit_entries(
WorkspaceWalkOptions {
root: temp.path(),
skip_dirs: &["target", "node_modules"],
cancel_interval: 32,
},
None,
|path, _| {
both.push(path.to_path_buf());
Ok(true)
},
)
.unwrap();
let names = names(&both);
assert!(!names.contains(&"hidden.txt".to_string()));
assert!(!names.contains(&"visible.txt".to_string()));
}
#[test]
fn p4ignore_defaults_and_nested_precedence_prune_directories() {
let temp = tempfile::TempDir::new().unwrap();
fs::create_dir(temp.path().join("build")).unwrap();
fs::create_dir(temp.path().join("nested")).unwrap();
fs::create_dir(temp.path().join("nested/build")).unwrap();
fs::write(temp.path().join(".p4ignore"), "build/\n!important.txt\n").unwrap();
fs::write(temp.path().join("build/hidden.txt"), "").unwrap();
fs::write(temp.path().join("nested/.p4ignore"), "!build/\n").unwrap();
fs::write(temp.path().join("nested/build/visible.txt"), "").unwrap();
fs::write(temp.path().join("visible.txt"), "").unwrap();
let mut visited = Vec::new();
WorkspaceWalker::default()
.visit_entries_with_environment(
WorkspaceWalkOptions {
root: temp.path(),
skip_dirs: &[],
cancel_interval: 32,
},
None,
&P4Environment::with_vars([]),
false,
|path, _| {
visited.push(path.to_path_buf());
Ok(true)
},
)
.unwrap();
let paths = visited
.iter()
.map(|path| {
path.strip_prefix(temp.path())
.unwrap()
.to_string_lossy()
.replace('\\', "/")
})
.collect::<Vec<_>>();
assert!(paths.contains(&"nested/build/visible.txt".to_string()));
assert!(!paths.contains(&"build/hidden.txt".to_string()));
assert!(paths.contains(&"visible.txt".to_string()));
}
#[test]
fn p4ignore_git_compatible_syntax_is_applied_by_shared_walker() {
let temp = tempfile::TempDir::new().unwrap();
fs::create_dir(temp.path().join("src")).unwrap();
fs::create_dir(temp.path().join("logs")).unwrap();
fs::write(
temp.path().join(".p4ignore"),
"# comment\n/*.tmp\n*.cache\nlogs/\n**/deep.txt\n",
)
.unwrap();
for file in ["#kept.txt", "root.tmp", "data.cache", "src/deep.txt"] {
if let Some(parent) = temp.path().join(file).parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(temp.path().join(file), "").unwrap();
}
fs::write(temp.path().join("logs/ignored.txt"), "").unwrap();
fs::write(temp.path().join("src/kept.txt"), "").unwrap();
let mut visited = Vec::new();
WorkspaceWalker::default()
.visit_entries_with_environment(
WorkspaceWalkOptions {
root: temp.path(),
skip_dirs: &[],
cancel_interval: 32,
},
None,
&P4Environment::with_vars([]),
false,
|path, ty| {
if ty.is_file() {
visited.push(path.to_path_buf());
}
Ok(true)
},
)
.unwrap();
let paths = visited
.iter()
.map(|path| {
path.strip_prefix(temp.path())
.unwrap()
.to_string_lossy()
.replace('\\', "/")
})
.collect::<Vec<_>>();
assert!(paths.contains(&"src/kept.txt".to_string()));
assert!(!paths.contains(&"root.tmp".to_string()));
assert!(!paths.contains(&"data.cache".to_string()));
assert!(!paths.contains(&"src/deep.txt".to_string()));
assert!(!paths.contains(&"logs/ignored.txt".to_string()));
}
#[test]
fn p4_negation_does_not_override_gitignore() {
let temp = tempfile::TempDir::new().unwrap();
fs::create_dir(temp.path().join(".git")).unwrap();
fs::write(temp.path().join(".gitignore"), "secret.txt\n").unwrap();
fs::write(temp.path().join(".p4ignore"), "!secret.txt\n").unwrap();
fs::write(temp.path().join("secret.txt"), "secret").unwrap();
let mut paths = Vec::new();
WorkspaceWalker::default()
.visit_entries_with_environment(
WorkspaceWalkOptions {
root: temp.path(),
skip_dirs: &[],
cancel_interval: 32,
},
None,
&P4Environment::with_vars([]),
false,
|path, ty| {
if ty.is_file() {
paths.push(path.to_path_buf());
}
Ok(true)
},
)
.unwrap();
assert!(!names(&paths).contains(&"secret.txt".to_string()));
}
#[test]
fn ignored_directory_is_pruned_before_nested_p4ignore_is_loaded() {
let temp = tempfile::TempDir::new().unwrap();
fs::create_dir(temp.path().join("generated")).unwrap();
fs::write(temp.path().join(".p4ignore"), "generated/\n").unwrap();
fs::write(temp.path().join("generated/.p4ignore"), "[\n").unwrap();
fs::write(temp.path().join("generated/large.txt"), "large").unwrap();
let mut paths = Vec::new();
WorkspaceWalker::default()
.visit_entries_with_environment(
WorkspaceWalkOptions {
root: temp.path(),
skip_dirs: &[],
cancel_interval: 32,
},
None,
&P4Environment::with_vars([]),
false,
|path, _| {
paths.push(path.to_path_buf());
Ok(true)
},
)
.unwrap();
assert!(!names(&paths).contains(&"large.txt".to_string()));
}
#[test]
fn descendant_negation_descends_without_returning_ignored_directory() {
let temp = tempfile::TempDir::new().unwrap();
fs::create_dir(temp.path().join("cache")).unwrap();
fs::write(temp.path().join(".p4ignore"), "cache/\n!cache/keep.rs\n").unwrap();
fs::write(temp.path().join("cache/generated.rs"), "").unwrap();
fs::write(temp.path().join("cache/keep.rs"), "").unwrap();
let mut visited = Vec::new();
WorkspaceWalker::default()
.visit_entries_with_environment(
WorkspaceWalkOptions {
root: temp.path(),
skip_dirs: &[],
cancel_interval: 32,
},
None,
&P4Environment::with_vars([]),
false,
|path, _| {
visited.push(path.to_path_buf());
Ok(true)
},
)
.unwrap();
let paths = visited
.iter()
.map(|path| {
path.strip_prefix(temp.path())
.unwrap()
.to_string_lossy()
.replace('\\', "/")
})
.collect::<Vec<_>>();
assert!(paths.contains(&"cache/keep.rs".to_string()));
assert!(!paths.contains(&"cache".to_string()));
assert!(!paths.contains(&"cache/generated.rs".to_string()));
}
}