use super::p4ignore::{P4Environment, P4Matcher};
use crate::agent::cancellation::AgentCancellation;
use ignore::WalkBuilder;
use std::{
collections::HashSet,
fs,
path::{Path, PathBuf},
};
#[derive(Debug, Default)]
pub(super) struct WorkspaceWalker;
pub(super) struct WorkspaceWalkOptions<'a> {
pub(super) root: &'a Path,
pub(super) include_files: bool,
pub(super) include_dirs: bool,
pub(super) skip_dirs: &'a [&'a str],
pub(super) cancel_interval: usize,
}
#[derive(Debug, Clone)]
pub(super) struct WorkspaceWalkEntry {
pub(super) path: PathBuf,
pub(super) file_type: fs::FileType,
}
#[derive(Debug, Clone, Default)]
pub(super) struct WorkspaceWalkResult {
pub(super) entries: Vec<WorkspaceWalkEntry>,
pub(super) agents_files: Vec<PathBuf>,
}
impl WorkspaceWalker {
pub(super) fn walk(
&self,
options: WorkspaceWalkOptions<'_>,
cancellation: Option<&AgentCancellation>,
) -> anyhow::Result<WorkspaceWalkResult> {
self.walk_with_environment(options, cancellation, &P4Environment::process())
}
fn walk_with_environment(
&self,
options: WorkspaceWalkOptions<'_>,
cancellation: Option<&AgentCancellation>,
environment: &P4Environment,
) -> anyhow::Result<WorkspaceWalkResult> {
let mut result = WorkspaceWalkResult::default();
let (walker, p4_matcher) = configured_walk(&options, environment)?;
let cancel_interval = options.cancel_interval.max(1);
for (entry_index, entry) in walker.enumerate() {
if entry_index % cancel_interval == 0
&& let Some(cancellation) = cancellation
{
cancellation.check()?;
}
let Ok(entry) = entry else { continue };
let Some(file_type) = entry.file_type() else {
continue;
};
let path = entry.path();
if path == options.root {
continue;
}
let is_dir = file_type.is_dir();
if p4_matcher.is_ignored(path, is_dir) {
continue;
}
if path.file_name().is_some_and(|name| name == "AGENTS.md") {
result.agents_files.push(path.to_path_buf());
}
if (file_type.is_file() && options.include_files)
|| (file_type.is_dir() && options.include_dirs)
{
result.entries.push(WorkspaceWalkEntry {
path: path.to_path_buf(),
file_type,
});
}
}
if let Some(error) = p4_matcher.take_error() {
return Err(error);
}
Ok(result)
}
pub(super) fn visit_files<F>(
&self,
options: WorkspaceWalkOptions<'_>,
cancellation: Option<&AgentCancellation>,
mut visitor: F,
) -> anyhow::Result<()>
where
F: FnMut(PathBuf) -> anyhow::Result<bool>,
{
let environment = P4Environment::process();
let (walker, p4_matcher) = configured_walk(&options, &environment)?;
let cancel_interval = options.cancel_interval.max(1);
for (entry_index, entry) in walker.enumerate() {
if entry_index % cancel_interval == 0
&& let Some(cancellation) = cancellation
{
cancellation.check()?;
}
let Ok(entry) = entry else { continue };
let Some(file_type) = entry.file_type() else {
continue;
};
let path = entry.path();
if path == options.root || !file_type.is_file() || p4_matcher.is_ignored(path, false) {
continue;
}
if !visitor(path.to_path_buf())? {
break;
}
}
if let Some(error) = p4_matcher.take_error() {
return Err(error);
}
Ok(())
}
}
fn configured_walk(
options: &WorkspaceWalkOptions<'_>,
environment: &P4Environment,
) -> 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);
builder.filter_entry(move |entry| {
let is_dir = entry
.file_type()
.is_some_and(|file_type| file_type.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(result: &WorkspaceWalkResult) -> Vec<String> {
let mut names = result
.entries
.iter()
.map(|entry| {
entry
.path
.file_name()
.unwrap()
.to_string_lossy()
.into_owned()
})
.collect::<Vec<_>>();
names.sort();
names
}
#[test]
fn walk_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 result = WorkspaceWalker
.walk(
WorkspaceWalkOptions {
root: temp.path(),
include_files: true,
include_dirs: false,
skip_dirs: &[],
cancel_interval: 32,
},
None,
)
.unwrap();
let names = names(&result);
assert!(names.contains(&"visible.txt".to_string()));
assert!(!names.contains(&"ignored.txt".to_string()));
}
#[test]
fn walk_discovers_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 result = WorkspaceWalker
.walk(
WorkspaceWalkOptions {
root: temp.path(),
include_files: false,
include_dirs: false,
skip_dirs: &[],
cancel_interval: 32,
},
None,
)
.unwrap();
assert_eq!(result.entries.len(), 0);
assert_eq!(result.agents_files.len(), 2);
assert!(
result
.agents_files
.iter()
.any(|path| path.ends_with("AGENTS.md"))
);
assert!(
result
.agents_files
.iter()
.any(|path| path.ends_with("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 target_only = WorkspaceWalker
.walk(
WorkspaceWalkOptions {
root: temp.path(),
include_files: true,
include_dirs: true,
skip_dirs: &["target"],
cancel_interval: 32,
},
None,
)
.unwrap();
let target_names = names(&target_only);
assert!(!target_names.contains(&"hidden.txt".to_string()));
assert!(target_names.contains(&"visible.txt".to_string()));
let both = WorkspaceWalker
.walk(
WorkspaceWalkOptions {
root: temp.path(),
include_files: true,
include_dirs: true,
skip_dirs: &["target", "node_modules"],
cancel_interval: 32,
},
None,
)
.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 result = WorkspaceWalker
.walk_with_environment(
WorkspaceWalkOptions {
root: temp.path(),
include_files: true,
include_dirs: true,
skip_dirs: &[],
cancel_interval: 32,
},
None,
&P4Environment::with_vars([]),
)
.unwrap();
let paths = result
.entries
.iter()
.map(|entry| {
entry
.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 result = WorkspaceWalker
.walk_with_environment(
WorkspaceWalkOptions {
root: temp.path(),
include_files: true,
include_dirs: false,
skip_dirs: &[],
cancel_interval: 32,
},
None,
&P4Environment::with_vars([]),
)
.unwrap();
let paths = result
.entries
.iter()
.map(|entry| {
entry
.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 result = WorkspaceWalker
.walk_with_environment(
WorkspaceWalkOptions {
root: temp.path(),
include_files: true,
include_dirs: false,
skip_dirs: &[],
cancel_interval: 32,
},
None,
&P4Environment::with_vars([]),
)
.unwrap();
assert!(!names(&result).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 result = WorkspaceWalker
.walk_with_environment(
WorkspaceWalkOptions {
root: temp.path(),
include_files: true,
include_dirs: true,
skip_dirs: &[],
cancel_interval: 32,
},
None,
&P4Environment::with_vars([]),
)
.unwrap();
assert!(!names(&result).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 result = WorkspaceWalker
.walk_with_environment(
WorkspaceWalkOptions {
root: temp.path(),
include_files: true,
include_dirs: true,
skip_dirs: &[],
cancel_interval: 32,
},
None,
&P4Environment::with_vars([]),
)
.unwrap();
let paths = result
.entries
.iter()
.map(|entry| {
entry
.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()));
}
}