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> {
let mut result = WorkspaceWalkResult::default();
let cancel_interval = options.cancel_interval.max(1);
let skip_dirs = options
.skip_dirs
.iter()
.map(|name| (*name).to_string())
.collect::<HashSet<_>>();
let walker = WalkBuilder::new(options.root)
.standard_filters(true)
.filter_entry(move |entry| {
let name = entry.file_name().to_string_lossy();
!(entry
.file_type()
.is_some_and(|file_type| file_type.is_dir())
&& skip_dirs.contains(name.as_ref()))
})
.build();
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;
}
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,
});
}
}
Ok(result)
}
}
#[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()));
}
}