use crate::cli::global::GlobalFlags;
use serde::Serialize;
use std::path::Path;
pub(crate) const DEFAULT_LIST_MAX_RESULTS: usize = 500;
#[derive(Debug, Clone, Serialize)]
pub(crate) struct ListFilesReport {
pub ok: bool,
pub paths: Vec<String>,
pub count: usize,
pub truncated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub total_matched: Option<usize>,
pub roots: Vec<String>,
}
pub(crate) fn collect_list_files(
roots: &[String],
global: &GlobalFlags,
cwd: &Path,
max_results: usize,
max_depth: Option<usize>,
include_hidden: bool,
) -> anyhow::Result<ListFilesReport> {
let cap = if max_results == 0 {
DEFAULT_LIST_MAX_RESULTS
} else {
max_results
};
let mut paths = crate::files::collect_file_paths_opts_depth(
roots,
global,
include_hidden,
Some(cwd),
max_depth,
)?;
let glob_matcher = crate::files::build_glob_matcher_from_global(global)?;
if glob_matcher.is_some() {
let glob_roots = crate::files::collect_glob_roots_from_global(roots, global, Some(cwd))?;
paths.retain(|p| {
crate::files::matches_glob_with_roots(p, glob_matcher.as_ref(), &glob_roots)
});
}
paths.sort();
let total = paths.len();
let truncated = total > cap;
if truncated {
paths.truncate(cap);
}
let rel: Vec<String> = paths.iter().map(|p| display_rel(cwd, p)).collect();
Ok(ListFilesReport {
ok: true,
count: rel.len(),
paths: rel,
truncated,
total_matched: if truncated { Some(total) } else { None },
roots: roots.to_vec(),
})
}
fn display_rel(cwd: &Path, path: &Path) -> String {
match path.strip_prefix(cwd) {
Ok(rel) if !rel.as_os_str().is_empty() => rel.to_string_lossy().replace('\\', "/"),
Ok(_) => ".".into(),
Err(_) => path.to_string_lossy().replace('\\', "/"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn write_tree(dir: &Path) {
fs::create_dir_all(dir.join("src/nested")).unwrap();
fs::write(dir.join("src/a.rs"), "a\n").unwrap();
fs::write(dir.join("src/nested/b.rs"), "b\n").unwrap();
fs::write(dir.join("README.md"), "r\n").unwrap();
fs::create_dir_all(dir.join("vendor")).unwrap();
fs::write(dir.join("vendor/x.js"), "x\n").unwrap();
fs::write(dir.join("src/lib.ts"), "t\n").unwrap();
}
#[test]
fn lists_files_sorted_relative() {
let dir = TempDir::new().unwrap();
write_tree(dir.path());
let global = GlobalFlags::test_default();
let report =
collect_list_files(&[".".into()], &global, dir.path(), 500, None, false).unwrap();
assert!(report.ok);
assert!(
report
.paths
.iter()
.any(|p| p == "README.md" || p.ends_with("README.md"))
);
assert!(report.paths.iter().any(|p| p.contains("a.rs")));
assert!(!report.truncated);
let mut sorted = report.paths.clone();
sorted.sort();
assert_eq!(report.paths, sorted);
}
#[test]
fn exclude_and_max_results_truncate() {
let dir = TempDir::new().unwrap();
write_tree(dir.path());
let mut global = GlobalFlags::test_default();
global.exclude = vec!["vendor/**".into()];
let report =
collect_list_files(&[".".into()], &global, dir.path(), 2, None, false).unwrap();
assert!(!report.paths.iter().any(|p| p.contains("vendor")));
assert_eq!(report.count, 2);
assert!(report.truncated);
assert!(report.total_matched.unwrap() >= 3);
}
#[test]
fn max_depth_limits_nested() {
let dir = TempDir::new().unwrap();
write_tree(dir.path());
let global = GlobalFlags::test_default();
let report =
collect_list_files(&[".".into()], &global, dir.path(), 500, Some(1), false).unwrap();
assert!(
report.paths.iter().all(|p| !p.contains('/')),
"depth 1 must not include nested paths: {:?}",
report.paths
);
assert!(report.paths.iter().any(|p| p == "README.md"));
}
#[test]
fn max_depth_relative_to_non_dot_root() {
let dir = TempDir::new().unwrap();
write_tree(dir.path());
let global = GlobalFlags::test_default();
let report =
collect_list_files(&["src".into()], &global, dir.path(), 500, Some(1), false).unwrap();
assert!(
report.paths.iter().any(|p| p.ends_with("a.rs")),
"expected a.rs under src depth 1: {:?}",
report.paths
);
assert!(
report.paths.iter().any(|p| p.ends_with("lib.ts")),
"expected lib.ts under src depth 1: {:?}",
report.paths
);
assert!(
!report.paths.iter().any(|p| p.contains("nested")),
"nested must be depth 2 under src: {:?}",
report.paths
);
}
#[test]
fn max_depth_prunes_very_deep_tree() {
let dir = TempDir::new().unwrap();
let mut deep = dir.path().to_path_buf();
for i in 0..12 {
deep.push(format!("d{i}"));
fs::create_dir_all(&deep).unwrap();
}
fs::write(deep.join("leaf.txt"), "leaf\n").unwrap();
fs::write(dir.path().join("top.txt"), "top\n").unwrap();
let global = GlobalFlags::test_default();
let report =
collect_list_files(&[".".into()], &global, dir.path(), 500, Some(1), false).unwrap();
assert!(
report
.paths
.iter()
.any(|p| p == "top.txt" || p.ends_with("top.txt")),
"top-level file: {:?}",
report.paths
);
assert!(
!report.paths.iter().any(|p| p.contains("leaf")),
"depth-12 leaf must be pruned: {:?}",
report.paths
);
let deep_report =
collect_list_files(&[".".into()], &global, dir.path(), 500, None, false).unwrap();
assert!(
deep_report.paths.iter().any(|p| p.contains("leaf")),
"unlimited must find leaf: {:?}",
deep_report.paths
);
}
#[test]
fn globs_filter_include() {
let dir = TempDir::new().unwrap();
write_tree(dir.path());
let mut global = GlobalFlags::test_default();
global.glob = vec!["**/*.rs".into()];
let report =
collect_list_files(&[".".into()], &global, dir.path(), 500, None, false).unwrap();
assert!(
!report.paths.is_empty(),
"glob **/*.rs must match: {:?}",
report.paths
);
assert!(
report.paths.iter().all(|p| p.ends_with(".rs")),
"only .rs: {:?}",
report.paths
);
assert!(!report.paths.iter().any(|p| p.ends_with(".md")));
assert!(!report.paths.iter().any(|p| p.ends_with(".js")));
assert!(!report.paths.iter().any(|p| p.ends_with(".ts")));
}
#[test]
fn zero_max_results_uses_default_cap() {
assert_eq!(DEFAULT_LIST_MAX_RESULTS, 500);
let dir = TempDir::new().unwrap();
write_tree(dir.path());
let global = GlobalFlags::test_default();
let report =
collect_list_files(&[".".into()], &global, dir.path(), 0, None, false).unwrap();
assert!(report.count <= DEFAULT_LIST_MAX_RESULTS);
assert!(!report.truncated);
assert!(report.total_matched.is_none());
}
#[test]
fn total_matched_when_truncated() {
let dir = TempDir::new().unwrap();
write_tree(dir.path());
let global = GlobalFlags::test_default();
let report =
collect_list_files(&[".".into()], &global, dir.path(), 1, None, false).unwrap();
assert!(report.truncated);
assert_eq!(report.count, 1);
let total = report.total_matched.expect("total_matched when truncated");
assert!(total > 1, "total_matched={total}");
}
#[test]
fn include_hidden_lists_dotfiles() {
let dir = TempDir::new().unwrap();
write_tree(dir.path());
fs::write(dir.path().join(".secret"), "s\n").unwrap();
let global = GlobalFlags::test_default();
let hidden_off =
collect_list_files(&[".".into()], &global, dir.path(), 500, None, false).unwrap();
assert!(
!hidden_off.paths.iter().any(|p| p.contains(".secret")),
"hidden off must skip .secret: {:?}",
hidden_off.paths
);
let hidden_on =
collect_list_files(&[".".into()], &global, dir.path(), 500, None, true).unwrap();
assert!(
hidden_on
.paths
.iter()
.any(|p| p.ends_with(".secret") || p == ".secret"),
"include_hidden must list .secret: {:?}",
hidden_on.paths
);
}
#[test]
fn invalid_glob_pattern_errors() {
let dir = TempDir::new().unwrap();
write_tree(dir.path());
let mut global = GlobalFlags::test_default();
global.glob = vec!["**/*[".into()];
let err = collect_list_files(&[".".into()], &global, dir.path(), 500, None, false)
.expect_err("invalid glob must fail");
let msg = format!("{err:#}");
assert!(!msg.is_empty(), "error should describe invalid glob: {msg}");
}
#[test]
fn multi_root_union_sorted() {
let dir = TempDir::new().unwrap();
write_tree(dir.path());
let global = GlobalFlags::test_default();
let report = collect_list_files(
&["src".into(), "vendor".into()],
&global,
dir.path(),
500,
None,
false,
)
.unwrap();
assert!(
report.paths.iter().any(|p| p.contains("a.rs")),
"src root: {:?}",
report.paths
);
assert!(
report.paths.iter().any(|p| p.contains("x.js")),
"vendor root: {:?}",
report.paths
);
assert!(
!report
.paths
.iter()
.any(|p| p == "README.md" || p.ends_with("README.md")),
"README is outside roots: {:?}",
report.paths
);
let mut sorted = report.paths.clone();
sorted.sort();
assert_eq!(report.paths, sorted);
assert_eq!(report.roots, vec!["src".to_string(), "vendor".to_string()]);
}
#[test]
fn multi_root_max_depth_prunes_each_root() {
let dir = TempDir::new().unwrap();
fs::create_dir_all(dir.path().join("a/nested")).unwrap();
fs::create_dir_all(dir.path().join("b/nested")).unwrap();
fs::write(dir.path().join("a/top_a.txt"), "a\n").unwrap();
fs::write(dir.path().join("a/nested/deep_a.txt"), "da\n").unwrap();
fs::write(dir.path().join("b/top_b.txt"), "b\n").unwrap();
fs::write(dir.path().join("b/nested/deep_b.txt"), "db\n").unwrap();
fs::write(dir.path().join("root.md"), "r\n").unwrap();
let global = GlobalFlags::test_default();
let report = collect_list_files(
&["a".into(), "b".into()],
&global,
dir.path(),
500,
Some(1),
false,
)
.unwrap();
let joined = report.paths.join(" ");
assert!(joined.contains("top_a"), "a top: {:?}", report.paths);
assert!(joined.contains("top_b"), "b top: {:?}", report.paths);
assert!(
!joined.contains("deep_"),
"deep pruned per root: {:?}",
report.paths
);
assert!(
!joined.contains("root.md"),
"outside multi-root: {:?}",
report.paths
);
}
}