use std::collections::HashSet;
use std::io;
use std::path::{Path, PathBuf};
use serde_json::json;
use std::io::IsTerminal;
use crate::fs_utils::{
GitIgnoreChecker, build_ignore_matchers, count_lines, is_allowed_hidden, should_ignore,
sort_dir_entries,
};
use crate::types::{
COLOR_RED, COLOR_RESET, Collectors, ColorMode, LargeEntry, LineEntry, Options, OutputMode,
Stats,
};
const BUILD_ARTIFACT_DIRS: &[&str] = &[
"node_modules",
".pnpm-store",
"vendor",
".venv",
"venv",
"env",
"ENV",
"target",
"dist",
"build",
"out",
"coverage",
".tox",
".mypy_cache",
".pytest_cache",
".gradle",
".parcel-cache",
".next",
".nuxt",
".turbo",
".cache",
".dart_tool",
".terraform",
".terraform.d",
"Pods",
"DerivedData",
".expo",
".expo-shared",
".svelte-kit",
".angular",
".vercel",
".serverless",
];
#[allow(clippy::too_many_arguments, clippy::only_used_in_recursion)]
fn walk(
dir: &Path,
options: &Options,
prefix_parts: &mut Vec<bool>,
collectors: &mut Collectors,
depth: usize,
root: &Path,
root_canon: &Path,
git_checker: Option<&GitIgnoreChecker>,
visited: &mut HashSet<PathBuf>,
) -> io::Result<bool> {
let dir_canon = dir.canonicalize()?;
if !dir_canon.starts_with(root_canon) {
return Ok(false);
}
if !visited.insert(dir_canon.clone()) {
return Ok(false);
}
let mut dir_entries: Vec<_> = std::fs::read_dir(&dir_canon)?
.filter_map(Result::ok)
.filter(|entry| {
let name = entry.file_name();
let name_str = name.to_string_lossy();
let is_hidden = name_str.starts_with('.');
options.show_hidden || !is_hidden || is_allowed_hidden(&name_str)
})
.collect();
sort_dir_entries(dir_entries.as_mut_slice());
let len = dir_entries.len();
let mut any_included = false;
for (idx, entry) in dir_entries.into_iter().enumerate() {
let path = entry.path();
let is_last = idx + 1 == len;
let mut prefix = String::new();
for &has_more in prefix_parts.iter() {
if has_more {
prefix.push_str("│ ");
} else {
prefix.push_str(" ");
}
}
let branch = if is_last { "└── " } else { "├── " };
let name = entry.file_name().to_string_lossy().to_string();
let label = format!("{}{}{}", prefix, branch, name);
let relative = path
.canonicalize()
.unwrap_or_else(|_| path.clone())
.strip_prefix(root_canon)
.unwrap_or(&path)
.to_path_buf();
if options.find_artifacts {
let is_dir = path.is_dir();
if !is_dir {
continue;
}
let is_artifact = BUILD_ARTIFACT_DIRS.contains(&name.as_str());
if is_artifact {
let relative_display = if relative.as_os_str().is_empty() {
name.clone()
} else {
relative.to_string_lossy().to_string()
};
collectors.entries.push(LineEntry {
label: relative_display.clone(),
loc: None,
relative_path: relative_display,
is_dir: true,
is_large: false,
});
collectors.stats.directories += 1;
any_included = true;
continue;
}
if options.max_depth.is_none_or(|max| depth < max) {
prefix_parts.push(!is_last);
let child_has = walk(
&path,
options,
prefix_parts,
collectors,
depth + 1,
root,
root_canon,
git_checker,
visited,
)?;
prefix_parts.pop();
if child_has {
any_included = true;
}
}
continue;
}
if options.show_ignored {
let is_gitignored = git_checker
.map(|checker| checker.is_ignored(&path))
.unwrap_or(false);
if !is_gitignored {
if path.is_dir() && options.max_depth.is_none_or(|max| depth < max) {
prefix_parts.push(!is_last);
let _ = walk(
&path,
options,
prefix_parts,
collectors,
depth + 1,
root,
root_canon,
git_checker,
visited,
);
prefix_parts.pop();
}
continue;
}
} else if should_ignore(&path, options, git_checker) {
continue;
}
let mut loc = None;
let is_dir = path.is_dir();
let mut include_current = false;
if path.is_file() {
let ext = path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("")
.to_lowercase();
let matches_ext = options
.extensions
.as_ref()
.is_none_or(|set| set.contains(&ext));
if matches_ext {
loc = count_lines(&path);
if let Some(value) = loc {
collectors.stats.files += 1;
collectors.stats.files_with_loc += 1;
collectors.stats.total_loc += value;
if value >= options.loc_threshold {
let relative_display = if relative.as_os_str().is_empty() {
name.clone()
} else {
relative.to_string_lossy().to_string()
};
collectors.large_entries.push(LargeEntry {
path: relative_display.clone(),
loc: value,
});
}
include_current = true;
}
}
}
let relative_display = if relative.as_os_str().is_empty() {
name.clone()
} else {
relative.to_string_lossy().to_string()
};
let is_large = loc.is_some_and(|v| v >= options.loc_threshold);
if is_dir && options.max_depth.is_none_or(|max| depth < max) {
let insert_pos = collectors.entries.len();
prefix_parts.push(!is_last);
let child_has = walk(
&path,
options,
prefix_parts,
collectors,
depth + 1,
root,
root_canon,
git_checker,
visited,
)?;
prefix_parts.pop();
if child_has {
collectors.stats.directories += 1;
collectors.entries.insert(
insert_pos,
LineEntry {
label,
loc,
relative_path: relative_display,
is_dir,
is_large,
},
);
any_included = true;
}
} else if include_current {
collectors.entries.push(LineEntry {
label,
loc,
relative_path: relative_display,
is_dir,
is_large,
});
any_included = true;
}
}
Ok(any_included)
}
pub fn run_tree(root_list: &[PathBuf], parsed: &crate::args::ParsedArgs) -> io::Result<()> {
let options = Options {
extensions: parsed.extensions.clone(),
ignore_paths: Vec::new(),
ignore_globs: None,
use_gitignore: parsed.use_gitignore,
max_depth: parsed.max_depth,
color: parsed.color,
output: parsed.output,
summary: parsed.summary,
summary_limit: parsed.summary_limit,
summary_only: parsed.summary_only,
show_hidden: parsed.show_hidden,
show_ignored: parsed.show_ignored,
loc_threshold: parsed.loc_threshold,
analyze_limit: parsed.analyze_limit,
report_path: None,
serve: false,
editor_cmd: None,
max_graph_nodes: parsed.max_graph_nodes,
max_graph_edges: parsed.max_graph_edges,
verbose: parsed.verbose,
scan_all: parsed.scan_all,
symbol: None,
impact: None,
find_artifacts: parsed.find_artifacts,
};
let mut json_results = Vec::new();
for (idx, root_path) in root_list.iter().enumerate() {
let ignore_matchers = build_ignore_matchers(&parsed.ignore_patterns, root_path);
let root_canon = root_path
.canonicalize()
.unwrap_or_else(|_| root_path.clone());
let root_options = Options {
ignore_paths: ignore_matchers.ignore_paths,
ignore_globs: ignore_matchers.ignore_globs,
loc_threshold: parsed.loc_threshold,
..options.clone()
};
let git_checker = if root_options.use_gitignore {
GitIgnoreChecker::new(root_path)
} else {
None
};
let mut entries: Vec<LineEntry> = Vec::new();
let mut large_entries: Vec<LargeEntry> = Vec::new();
let mut prefix_parts: Vec<bool> = Vec::new();
let mut stats = Stats::default();
let mut visited: HashSet<PathBuf> = HashSet::new();
let mut collectors = Collectors {
entries: &mut entries,
large_entries: &mut large_entries,
stats: &mut stats,
};
walk(
root_path,
&root_options,
&mut prefix_parts,
&mut collectors,
0,
root_path,
&root_canon,
git_checker.as_ref(),
&mut visited,
)?;
if root_options.find_artifacts {
for entry in &entries {
let abs_path = root_canon.join(&entry.relative_path);
println!("{}", abs_path.display());
}
continue;
}
let mut sorted_large = large_entries;
sorted_large.sort_by(|a, b| b.loc.cmp(&a.loc));
let summary = json!({
"directories": stats.directories,
"files": stats.files,
"filesWithLoc": stats.files_with_loc,
"totalLoc": stats.total_loc,
"largeFiles": sorted_large
.iter()
.take(root_options.summary_limit)
.map(|e| json!({"path": e.path, "loc": e.loc}))
.collect::<Vec<_>>()
});
if matches!(root_options.output, OutputMode::Json | OutputMode::Jsonl) {
let entries_json: Vec<_> = if root_options.summary_only {
sorted_large
.iter()
.take(root_options.summary_limit)
.map(|entry| {
json!({
"path": entry.path,
"type": "file",
"loc": entry.loc,
"isLarge": true,
})
})
.collect()
} else {
entries
.iter()
.map(|entry| {
json!({
"path": entry.relative_path,
"type": if entry.is_dir { "dir" } else { "file" },
"loc": entry.loc,
"isLarge": entry.is_large,
})
})
.collect()
};
let payload = json!({
"root": root_path,
"options": {
"exts": root_options.extensions.as_ref().map(|set| {
let mut exts: Vec<_> = set.iter().cloned().collect();
exts.sort();
exts
}),
"ignore": root_options
.ignore_paths
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>(),
"maxDepth": root_options.max_depth,
"useGitignore": root_options.use_gitignore,
"color": match root_options.color {
ColorMode::Auto => "auto",
ColorMode::Always => "always",
ColorMode::Never => "never",
},
"summary": if root_options.summary {
serde_json::Value::from(root_options.summary_limit)
} else {
serde_json::Value::Bool(false)
},
},
"summary": summary,
"entries": entries_json,
});
if matches!(root_options.output, OutputMode::Jsonl) {
match serde_json::to_string(&payload) {
Ok(line) => println!("{}", line),
Err(err) => {
eprintln!("[loctree][warn] failed to serialize JSONL line: {}", err)
}
}
} else {
json_results.push(payload);
}
continue;
}
if root_options.summary_only && matches!(root_options.output, OutputMode::Human) {
if idx > 0 {
println!();
}
let root_name = root_path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| root_path.display().to_string());
println!("{}/", root_name);
if sorted_large.is_empty() {
println!(
"No files exceed the large-file threshold ({} LOC).",
root_options.loc_threshold
);
} else {
println!(
"Top {} files (>= {} LOC):",
root_options.summary_limit, root_options.loc_threshold
);
for item in sorted_large.iter().take(root_options.summary_limit) {
println!(" {} ({} LOC)", item.path, item.loc);
}
}
println!(
"\nSummary: directories: {}, files: {}, files with LOC: {}, total LOC: {}",
stats.directories, stats.files, stats.files_with_loc, stats.total_loc
);
continue;
}
if idx > 0 {
println!();
}
if entries.is_empty() {
println!("{}/ (empty)", root_path.display());
continue;
}
let max_label_len = entries
.iter()
.map(|entry| entry.label.len())
.max()
.unwrap_or(0);
let root_name = root_path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| root_path.display().to_string());
let color_enabled = matches!(root_options.color, ColorMode::Always)
|| (matches!(root_options.color, ColorMode::Auto) && std::io::stdout().is_terminal());
println!("{}/", root_name);
for entry in &entries {
if let Some(loc) = entry.loc {
let line = format!("{:<width$} {:>6}", entry.label, loc, width = max_label_len);
if color_enabled && entry.is_large {
println!("{}{}{}", COLOR_RED, line, COLOR_RESET);
} else {
println!("{}", line);
}
} else {
println!("{}", entry.label);
}
}
if !sorted_large.is_empty() {
println!("\nLarge files (>= {} LOC):", root_options.loc_threshold);
for item in &sorted_large {
let summary_line = format!(" {} ({} LOC)", item.path, item.loc);
if color_enabled {
println!("{}{}{}", COLOR_RED, summary_line, COLOR_RESET);
} else {
println!("{}", summary_line);
}
}
}
if root_options.summary {
println!(
"\nSummary: directories: {}, files: {}, files with LOC: {}, total LOC: {}",
stats.directories, stats.files, stats.files_with_loc, stats.total_loc
);
if sorted_large.is_empty() {
println!("No files exceed the large-file threshold.");
}
}
}
if matches!(options.output, OutputMode::Json) {
if json_results.len() == 1 {
match serde_json::to_string_pretty(&json_results[0]) {
Ok(out) => println!("{}", out),
Err(err) => eprintln!("[loctree][warn] failed to serialize JSON: {}", err),
}
} else {
match serde_json::to_string_pretty(&json_results) {
Ok(out) => println!("{}", out),
Err(err) => eprintln!("[loctree][warn] failed to serialize JSON: {}", err),
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn create_test_tree() -> TempDir {
let temp = TempDir::new().unwrap();
let root = temp.path();
fs::create_dir_all(root.join("src")).unwrap();
fs::create_dir_all(root.join("lib")).unwrap();
fs::write(
root.join("src/main.ts"),
"export function main() {\n console.log('hello');\n}\n",
)
.unwrap();
fs::write(
root.join("src/utils.ts"),
"export const add = (a: number, b: number) => a + b;\n",
)
.unwrap();
fs::write(
root.join("lib/helper.ts"),
"export function help() { return 'help'; }\n",
)
.unwrap();
temp
}
fn default_parsed_args() -> crate::args::ParsedArgs {
crate::args::ParsedArgs::default()
}
#[test]
fn test_run_tree_basic() {
let temp = create_test_tree();
let roots = vec![temp.path().to_path_buf()];
let parsed = default_parsed_args();
let result = run_tree(&roots, &parsed);
assert!(result.is_ok());
}
#[test]
fn test_run_tree_with_summary() {
let temp = create_test_tree();
let roots = vec![temp.path().to_path_buf()];
let mut parsed = default_parsed_args();
parsed.summary = true;
let result = run_tree(&roots, &parsed);
assert!(result.is_ok());
}
#[test]
fn test_run_tree_json_output() {
let temp = create_test_tree();
let roots = vec![temp.path().to_path_buf()];
let mut parsed = default_parsed_args();
parsed.output = OutputMode::Json;
let result = run_tree(&roots, &parsed);
assert!(result.is_ok());
}
#[test]
fn test_run_tree_jsonl_output() {
let temp = create_test_tree();
let roots = vec![temp.path().to_path_buf()];
let mut parsed = default_parsed_args();
parsed.output = OutputMode::Jsonl;
let result = run_tree(&roots, &parsed);
assert!(result.is_ok());
}
#[test]
fn test_run_tree_with_extension_filter() {
let temp = create_test_tree();
let roots = vec![temp.path().to_path_buf()];
let mut parsed = default_parsed_args();
parsed.extensions = Some(["ts".to_string()].into_iter().collect());
let result = run_tree(&roots, &parsed);
assert!(result.is_ok());
}
#[test]
fn test_run_tree_with_max_depth() {
let temp = create_test_tree();
let roots = vec![temp.path().to_path_buf()];
let mut parsed = default_parsed_args();
parsed.max_depth = Some(1);
let result = run_tree(&roots, &parsed);
assert!(result.is_ok());
}
#[test]
fn test_run_tree_empty_directory() {
let temp = TempDir::new().unwrap();
let roots = vec![temp.path().to_path_buf()];
let parsed = default_parsed_args();
let result = run_tree(&roots, &parsed);
assert!(result.is_ok());
}
#[test]
fn test_run_tree_multiple_roots() {
let temp1 = create_test_tree();
let temp2 = create_test_tree();
let roots = vec![temp1.path().to_path_buf(), temp2.path().to_path_buf()];
let parsed = default_parsed_args();
let result = run_tree(&roots, &parsed);
assert!(result.is_ok());
}
#[test]
fn test_run_tree_show_hidden() {
let temp = TempDir::new().unwrap();
let root = temp.path();
fs::write(root.join(".hidden.ts"), "const hidden = true;\n").unwrap();
fs::write(root.join("visible.ts"), "const visible = true;\n").unwrap();
let roots = vec![temp.path().to_path_buf()];
let mut parsed = default_parsed_args();
parsed.show_hidden = true;
let result = run_tree(&roots, &parsed);
assert!(result.is_ok());
}
#[test]
fn test_run_tree_with_gitignore() {
let temp = TempDir::new().unwrap();
let root = temp.path();
fs::write(root.join(".gitignore"), "*.log\nnode_modules/\n").unwrap();
fs::write(root.join("app.ts"), "const app = 'app';\n").unwrap();
fs::write(root.join("debug.log"), "log content\n").unwrap();
let roots = vec![temp.path().to_path_buf()];
let mut parsed = default_parsed_args();
parsed.use_gitignore = true;
let result = run_tree(&roots, &parsed);
assert!(result.is_ok());
}
#[test]
fn test_run_tree_with_loc_threshold() {
let temp = TempDir::new().unwrap();
let root = temp.path();
let large_content: String = (0..100)
.map(|i| format!("const line{} = {};\n", i, i))
.collect();
fs::write(root.join("large.ts"), large_content).unwrap();
let roots = vec![temp.path().to_path_buf()];
let mut parsed = default_parsed_args();
parsed.loc_threshold = 50;
let result = run_tree(&roots, &parsed);
assert!(result.is_ok());
}
#[test]
fn test_find_artifacts_mode() {
let temp = TempDir::new().unwrap();
let root = temp.path();
fs::create_dir_all(root.join("node_modules")).unwrap();
fs::create_dir_all(root.join("dist")).unwrap();
fs::create_dir_all(root.join("target")).unwrap();
fs::create_dir_all(root.join("src")).unwrap();
fs::write(root.join("node_modules/package.json"), "{}").unwrap();
fs::write(root.join("src/main.ts"), "export default {};\n").unwrap();
let roots = vec![temp.path().to_path_buf()];
let mut parsed = default_parsed_args();
parsed.find_artifacts = true;
let result = run_tree(&roots, &parsed);
assert!(result.is_ok());
}
#[test]
fn test_build_artifact_dirs_constant() {
assert!(BUILD_ARTIFACT_DIRS.contains(&"node_modules"));
assert!(BUILD_ARTIFACT_DIRS.contains(&"target"));
assert!(BUILD_ARTIFACT_DIRS.contains(&"dist"));
assert!(BUILD_ARTIFACT_DIRS.contains(&".venv"));
assert!(BUILD_ARTIFACT_DIRS.contains(&"vendor"));
}
#[test]
fn test_run_tree_with_ignore_patterns() {
let temp = create_test_tree();
let roots = vec![temp.path().to_path_buf()];
let mut parsed = default_parsed_args();
parsed.ignore_patterns = vec!["lib".to_string()];
let result = run_tree(&roots, &parsed);
assert!(result.is_ok());
}
#[test]
fn test_run_tree_with_color_always() {
let temp = create_test_tree();
let roots = vec![temp.path().to_path_buf()];
let mut parsed = default_parsed_args();
parsed.color = ColorMode::Always;
let result = run_tree(&roots, &parsed);
assert!(result.is_ok());
}
#[test]
fn test_run_tree_nested_directories() {
let temp = TempDir::new().unwrap();
let root = temp.path();
fs::create_dir_all(root.join("a/b/c/d")).unwrap();
fs::write(
root.join("a/b/c/d/deep.ts"),
"export const deep = 'deep';\n",
)
.unwrap();
let roots = vec![temp.path().to_path_buf()];
let parsed = default_parsed_args();
let result = run_tree(&roots, &parsed);
assert!(result.is_ok());
}
}