use crate::types::EntryKind;
use crate::types::LsEntry;
use crate::types::Show;
use agentic_tools_core::ToolError;
use globset::Glob;
use globset::GlobSet;
use globset::GlobSetBuilder;
use ignore::WalkBuilder;
use std::path::Path;
pub const BUILTIN_IGNORES: &[&str] = &[
"**/node_modules",
"**/node_modules/**",
"**/__pycache__",
"**/__pycache__/**",
"**/dist",
"**/dist/**",
"**/build",
"**/build/**",
"**/target",
"**/target/**",
"**/vendor",
"**/vendor/**",
"**/bin",
"**/bin/**",
"**/obj",
"**/obj/**",
"**/.idea",
"**/.idea/**",
"**/.vscode",
"**/.vscode/**",
"**/.zig-cache",
"**/.zig-cache/**",
"**/zig-out",
"**/zig-out/**",
"**/.coverage",
"**/.coverage/**",
"**/coverage",
"**/coverage/**",
"**/tmp",
"**/tmp/**",
"**/temp",
"**/temp/**",
"**/.cache",
"**/.cache/**",
"**/cache",
"**/cache/**",
"**/logs",
"**/logs/**",
"**/.venv",
"**/.venv/**",
"**/venv",
"**/venv/**",
"**/env",
"**/env/**",
];
pub fn build_ignore_globset(user_patterns: &[String]) -> Result<GlobSet, ToolError> {
let mut builder = GlobSetBuilder::new();
for pattern in BUILTIN_IGNORES
.iter()
.copied()
.chain(user_patterns.iter().map(String::as_str))
{
let glob = Glob::new(pattern).map_err(|e| {
ToolError::invalid_input(format!("Invalid glob pattern '{pattern}': {e}"))
})?;
builder.add(glob);
}
builder
.build()
.map_err(|e| ToolError::internal(format!("Failed to build globset: {e}")))
}
pub struct WalkConfig<'a> {
pub root: &'a Path,
pub depth: u8,
pub show: Show,
pub user_ignores: &'a [String],
pub include_hidden: bool,
}
pub struct WalkResult {
pub entries: Vec<LsEntry>,
pub warnings: Vec<String>,
}
pub fn list(cfg: &WalkConfig<'_>) -> Result<WalkResult, ToolError> {
if cfg.depth == 0 {
return Ok(WalkResult {
entries: vec![],
warnings: vec![],
});
}
let globset = build_ignore_globset(cfg.user_ignores)?;
let mut builder = WalkBuilder::new(cfg.root);
builder.max_depth(Some(cfg.depth as usize));
builder.hidden(!cfg.include_hidden); builder.git_ignore(true);
builder.git_global(true);
builder.git_exclude(true);
builder.parents(false); builder.follow_links(false);
let root = cfg.root.to_path_buf();
let gs = globset.clone();
builder.filter_entry(move |entry| {
let rel = entry
.path()
.strip_prefix(&root)
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
if rel.is_empty() {
return true; }
!gs.is_match(&rel)
});
let mut entries = Vec::new();
let mut warnings = Vec::new();
for result in builder.build() {
match result {
Ok(entry) => {
if entry.depth() == 0 {
continue;
}
let rel = entry
.path()
.strip_prefix(cfg.root)
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
if globset.is_match(&rel) {
continue;
}
let file_type = entry.file_type();
let kind = match file_type {
Some(ft) if ft.is_dir() => EntryKind::Dir,
Some(ft) if ft.is_file() => EntryKind::File,
Some(ft) if ft.is_symlink() => EntryKind::Symlink,
_ => {
match std::fs::symlink_metadata(entry.path()) {
Ok(md) if md.file_type().is_symlink() => EntryKind::Symlink,
Ok(md) if md.is_dir() => EntryKind::Dir,
Ok(_) => EntryKind::File,
Err(err) => {
warnings.push(format!("Skipping {rel}: {err}"));
continue;
}
}
}
};
match cfg.show {
Show::Dirs if !matches!(kind, EntryKind::Dir) => continue,
Show::Files if matches!(kind, EntryKind::Dir) => continue,
_ => {}
}
entries.push(LsEntry { path: rel, kind });
}
Err(err) => {
warnings.push(format!("Walk error: {err}"));
}
}
}
sort_entries(&mut entries, cfg.show);
for entry in &entries {
if matches!(entry.kind, EntryKind::Symlink) {
let full_path = cfg.root.join(&entry.path);
if std::fs::metadata(&full_path).is_err() {
warnings.push(format!("Broken symlink: {}", entry.path));
}
}
}
Ok(WalkResult { entries, warnings })
}
fn sort_entries(entries: &mut [LsEntry], show: Show) {
match show {
Show::All => {
entries.sort_by(|a, b| {
match (&a.kind, &b.kind) {
(EntryKind::Dir, EntryKind::File | EntryKind::Symlink) => {
std::cmp::Ordering::Less
}
(EntryKind::File | EntryKind::Symlink, EntryKind::Dir) => {
std::cmp::Ordering::Greater
}
_ => a.path.to_lowercase().cmp(&b.path.to_lowercase()),
}
});
}
_ => {
entries.sort_by(|a, b| a.path.to_lowercase().cmp(&b.path.to_lowercase()));
}
}
}
#[cfg(test)]
#[expect(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn builtin_ignores_compile() {
let gs = build_ignore_globset(&[]).unwrap();
assert!(gs.is_match("node_modules/foo.js"));
assert!(gs.is_match("src/target/debug"));
assert!(!gs.is_match("src/main.rs"));
}
#[test]
fn user_patterns_work() {
let gs = build_ignore_globset(&["*.log".into(), "dist/".into()]).unwrap();
assert!(gs.is_match("app.log"));
assert!(gs.is_match("dist/bundle.js"));
}
#[test]
fn invalid_pattern_errors() {
let result = build_ignore_globset(&["[invalid".into()]);
assert!(result.is_err());
}
#[test]
fn sort_all_dirs_first() {
let mut entries = vec![
LsEntry {
path: "zebra.txt".into(),
kind: EntryKind::File,
},
LsEntry {
path: "alpha".into(),
kind: EntryKind::Dir,
},
LsEntry {
path: "beta.rs".into(),
kind: EntryKind::File,
},
LsEntry {
path: "gamma".into(),
kind: EntryKind::Dir,
},
];
sort_entries(&mut entries, Show::All);
assert_eq!(entries[0].path, "alpha");
assert_eq!(entries[1].path, "gamma");
assert_eq!(entries[2].path, "beta.rs");
assert_eq!(entries[3].path, "zebra.txt");
}
#[test]
fn sort_files_only_alphabetical() {
let mut entries = vec![
LsEntry {
path: "zebra.txt".into(),
kind: EntryKind::File,
},
LsEntry {
path: "alpha.txt".into(),
kind: EntryKind::File,
},
];
sort_entries(&mut entries, Show::Files);
assert_eq!(entries[0].path, "alpha.txt");
assert_eq!(entries[1].path, "zebra.txt");
}
}