use std::fmt::Write as _;
use std::path::Path;
use std::time::Duration;
use colored::Colorize;
use fallow_config::RulesConfig;
use fallow_core::results::{AnalysisResults, UnusedExport, UnusedMember};
use rustc_hash::{FxHashMap, FxHashSet};
use super::{
MAX_FLAT_ITEMS, build_grouped_by_file, build_section_header, format_path,
push_section_footer_rollup, push_section_footer_with_count,
};
use crate::report::grouping::OwnershipResolver;
use crate::report::{
Level, elide_common_prefix, plural, relative_path, severity_to_level, split_dir_filename,
};
const MAX_GROUPED_FILES: usize = 10;
const MAX_ITEMS_PER_FILE: usize = 5;
const DIR_ROLLUP_THRESHOLD: usize = 200;
const SCOPING_HINT_THRESHOLD: usize = 500;
fn truncation_hint(remaining: usize, total_issues: usize) -> String {
if remaining > SCOPING_HINT_THRESHOLD || total_issues > SCOPING_HINT_THRESHOLD {
format!(
"... and {remaining} more \u{2014} try --workspace <name> or --changed-since main to scope"
)
} else {
format!("... and {remaining} more (--format json for full list)")
}
}
fn is_test_path(path: &Path) -> bool {
path.components().any(|c| {
let s = c.as_os_str().to_string_lossy();
matches!(
s.as_ref(),
"test"
| "tests"
| "__tests__"
| "__test__"
| "spec"
| "specs"
| "__mocks__"
| "__fixtures__"
| "fixtures"
)
})
}
fn insert_test_src_split<T>(lines: &mut Vec<String>, items: &[T], get_path: impl Fn(&T) -> &Path) {
if items.len() < 5 {
return;
}
let test_count = items
.iter()
.filter(|item| is_test_path(get_path(item)))
.count();
let src_count = items.len() - test_count;
if test_count == 0 || src_count == 0 {
return;
}
let test_pct = (test_count * 100) / items.len();
if test_pct < 30 {
return;
}
let annotation = format!(
" {}",
format!("{src_count} in src, {test_count} in test directories").dimmed()
);
if lines.last().is_some_and(String::is_empty) {
let pos = lines.len() - 1;
lines.insert(pos, annotation);
} else {
lines.push(annotation);
}
}
pub(in crate::report) fn print_human(
results: &AnalysisResults,
root: &Path,
rules: &RulesConfig,
elapsed: Duration,
quiet: bool,
top: Option<usize>,
) {
if !quiet {
eprintln!();
emit_config_quality_signal(results, root);
}
for line in build_human_lines(results, root, rules, top) {
println!("{line}");
}
if !quiet {
let total = results.total_issues();
if total == 0 {
eprintln!(
"{}",
format!("\u{2713} No issues found ({:.2}s)", elapsed.as_secs_f64())
.green()
.bold()
);
} else {
let unused_file_set: FxHashSet<&std::path::Path> = results
.unused_files
.iter()
.map(|f| f.path.as_path())
.collect();
let suppressed_exports = results
.unused_exports
.iter()
.filter(|e| unused_file_set.contains(e.path.as_path()))
.count();
let suppressed_types = results
.unused_types
.iter()
.filter(|e| unused_file_set.contains(e.path.as_path()))
.count();
let summary = build_summary_footer(results, suppressed_exports, suppressed_types);
eprintln!(
"{}",
format!("\u{2717} {summary} ({:.2}s)", elapsed.as_secs_f64())
.red()
.bold()
);
}
}
}
#[expect(
clippy::too_many_lines,
reason = "report builder formatting all issue types"
)]
pub(in crate::report) fn build_human_lines(
results: &AnalysisResults,
root: &Path,
rules: &RulesConfig,
top: Option<usize>,
) -> Vec<String> {
let max_items = top.unwrap_or(MAX_FLAT_ITEMS);
let max_grouped_files = top.unwrap_or(MAX_GROUPED_FILES);
let total_issues = results.total_issues();
let mut lines = Vec::new();
let unused_file_set: FxHashSet<&std::path::Path> = results
.unused_files
.iter()
.map(|f| f.path.as_path())
.collect();
let filtered_exports: Vec<UnusedExport> = results
.unused_exports
.iter()
.filter(|e| !unused_file_set.contains(e.path.as_path()))
.cloned()
.collect();
let filtered_types: Vec<UnusedExport> = results
.unused_types
.iter()
.filter(|e| !unused_file_set.contains(e.path.as_path()))
.cloned()
.collect();
let suppressed_exports = results.unused_exports.len() - filtered_exports.len();
let suppressed_types = results.unused_types.len() - filtered_types.len();
let format_export = |e: &UnusedExport| -> String {
let tag = if e.is_re_export {
" (re-export)".dimmed().to_string()
} else {
String::new()
};
format!(
"{} {}{}",
format!(":{}", e.line).dimmed(),
e.export_name.bold(),
tag
)
};
let format_member = |m: &UnusedMember| -> String {
format!(
"{} {}",
format!(":{}", m.line).dimmed(),
format!("{}.{}", m.parent_name, m.member_name).bold()
)
};
let format_dep = |name: &str, pkg_path: &Path| -> String {
let pkg_label = relative_path(pkg_path, root).display().to_string();
if pkg_label == "package.json" {
format!("{}", name.bold())
} else {
format!("{} ({})", name.bold(), pkg_label.dimmed())
}
};
let has_unused_code = !results.unused_files.is_empty()
|| !filtered_exports.is_empty()
|| !filtered_types.is_empty()
|| !results.unused_enum_members.is_empty()
|| !results.unused_class_members.is_empty();
if has_unused_code {
lines.push(
"\u{2500}\u{2500} Unused Code \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
.dimmed()
.to_string(),
);
lines.push(String::new());
}
if results.unused_files.len() > DIR_ROLLUP_THRESHOLD {
build_dir_rollup_section(&mut lines, &results.unused_files, root, rules, total_issues);
} else {
build_human_section_ex(
&mut lines,
&results.unused_files,
"Unused files",
severity_to_level(rules.unused_files),
max_items,
total_issues,
|file| {
let path_str = relative_path(&file.path, root).display().to_string();
vec![format!(" {}", format_path(&path_str))]
},
);
}
insert_test_src_split(&mut lines, &results.unused_files, |f| &f.path);
build_human_grouped_section(
&mut lines,
&filtered_exports,
"Unused exports",
severity_to_level(rules.unused_exports),
root,
max_grouped_files,
|e| e.path.as_path(),
&format_export,
);
if suppressed_exports > 0 {
let pos = if lines.last().is_some_and(String::is_empty) {
lines.len() - 1
} else {
lines.len()
};
lines.insert(
pos,
format!(
" {}",
format!("({suppressed_exports} more in files already reported as unused)").dimmed()
),
);
}
insert_test_src_split(&mut lines, &filtered_exports, |e| &e.path);
build_human_grouped_section(
&mut lines,
&filtered_types,
"Unused type exports",
severity_to_level(rules.unused_types),
root,
max_grouped_files,
|e| e.path.as_path(),
&format_export,
);
if suppressed_types > 0 {
let pos = if lines.last().is_some_and(String::is_empty) {
lines.len() - 1
} else {
lines.len()
};
lines.insert(
pos,
format!(
" {}",
format!("({suppressed_types} more in files already reported as unused)").dimmed()
),
);
}
build_human_grouped_section(
&mut lines,
&results.unused_enum_members,
"Unused enum members",
severity_to_level(rules.unused_enum_members),
root,
max_grouped_files,
|m| m.path.as_path(),
&format_member,
);
build_human_grouped_section(
&mut lines,
&results.unused_class_members,
"Unused class members",
severity_to_level(rules.unused_class_members),
root,
max_grouped_files,
|m| m.path.as_path(),
&format_member,
);
let has_dependencies = !results.unused_dependencies.is_empty()
|| !results.unused_dev_dependencies.is_empty()
|| !results.unused_optional_dependencies.is_empty()
|| !results.unresolved_imports.is_empty()
|| !results.unlisted_dependencies.is_empty()
|| !results.type_only_dependencies.is_empty()
|| !results.test_only_dependencies.is_empty();
if has_dependencies {
lines.push(
"\u{2500}\u{2500} Dependencies \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
.dimmed()
.to_string(),
);
lines.push(String::new());
}
build_human_section_ex(
&mut lines,
&results.unused_dependencies,
"Unused dependencies",
severity_to_level(rules.unused_dependencies),
max_items,
total_issues,
|dep| vec![format!(" {}", format_dep(&dep.package_name, &dep.path))],
);
build_human_section_ex(
&mut lines,
&results.unused_dev_dependencies,
"Unused devDependencies",
severity_to_level(rules.unused_dev_dependencies),
max_items,
total_issues,
|dep| vec![format!(" {}", format_dep(&dep.package_name, &dep.path))],
);
build_human_section_ex(
&mut lines,
&results.unused_optional_dependencies,
"Unused optionalDependencies",
severity_to_level(rules.unused_optional_dependencies),
max_items,
total_issues,
|dep| vec![format!(" {}", format_dep(&dep.package_name, &dep.path))],
);
build_human_grouped_section(
&mut lines,
&results.unresolved_imports,
"Unresolved imports",
severity_to_level(rules.unresolved_imports),
root,
max_grouped_files,
|i| i.path.as_path(),
&|i| format!("{} {}", format!(":{}", i.line).dimmed(), i.specifier.bold()),
);
build_human_section_ex(
&mut lines,
&results.unlisted_dependencies,
"Unlisted dependencies",
severity_to_level(rules.unlisted_dependencies),
max_items,
total_issues,
|dep| vec![format!(" {}", dep.package_name.bold())],
);
build_human_section_ex(
&mut lines,
&results.type_only_dependencies,
"Type-only dependencies (consider moving to devDependencies)",
severity_to_level(rules.type_only_dependencies),
max_items,
total_issues,
|dep| vec![format!(" {}", format_dep(&dep.package_name, &dep.path))],
);
build_human_section_ex(
&mut lines,
&results.test_only_dependencies,
"Test-only production dependencies (consider moving to devDependencies)",
severity_to_level(rules.test_only_dependencies),
max_items,
total_issues,
|dep| vec![format!(" {}", format_dep(&dep.package_name, &dep.path))],
);
let has_structure = !results.duplicate_exports.is_empty()
|| !results.circular_dependencies.is_empty()
|| !results.boundary_violations.is_empty();
if has_structure {
lines.push(
"\u{2500}\u{2500} Structure \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
.dimmed()
.to_string(),
);
lines.push(String::new());
}
build_duplicate_exports_section(
&mut lines,
&results.duplicate_exports,
severity_to_level(rules.duplicate_exports),
root,
total_issues,
);
build_circular_deps_section(
&mut lines,
&results.circular_dependencies,
severity_to_level(rules.circular_dependencies),
root,
total_issues,
);
build_boundary_violations_section(
&mut lines,
&results.boundary_violations,
severity_to_level(rules.boundary_violation),
root,
total_issues,
);
if !results.stale_suppressions.is_empty() {
lines.push(
"\u{2500}\u{2500} Maintenance \u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
.dimmed()
.to_string(),
);
lines.push(String::new());
}
build_stale_suppressions_section(
&mut lines,
&results.stale_suppressions,
severity_to_level(rules.stale_suppressions),
root,
total_issues,
);
lines
}
fn build_dir_rollup_section(
lines: &mut Vec<String>,
unused_files: &[fallow_core::results::UnusedFile],
root: &Path,
rules: &RulesConfig,
total_issues: usize,
) {
if unused_files.is_empty() {
return;
}
let title = "Unused files";
let level = severity_to_level(rules.unused_files);
lines.push(build_section_header(title, unused_files.len(), level));
let mut dir_counts: Vec<(String, usize, bool)> = Vec::new();
let mut dir_map: FxHashMap<String, usize> = FxHashMap::default();
for f in unused_files {
let rel = relative_path(&f.path, root);
let (dir, is_dir) = if rel.components().count() <= 1 {
("(project root)".to_string(), false)
} else {
(
rel.components().next().map_or_else(
|| ".".to_string(),
|c| c.as_os_str().to_string_lossy().to_string(),
),
true,
)
};
if let Some(&idx) = dir_map.get(&dir) {
dir_counts[idx].1 += 1;
} else {
dir_map.insert(dir.clone(), dir_counts.len());
dir_counts.push((dir, 1, is_dir));
}
}
dir_counts.sort_by_key(|b| std::cmp::Reverse(b.1));
let total = unused_files.len();
let dominant = dir_counts
.iter()
.find(|(_, count, is_dir)| *is_dir && count * 100 / total.max(1) > 80)
.map(|(dir, _, _)| dir.clone());
let display_entries: Vec<(String, usize, bool)> = if let Some(ref dom_dir) = dominant {
let mut sub_counts: Vec<(String, usize, bool)> = Vec::new();
let mut sub_map: FxHashMap<String, usize> = FxHashMap::default();
for f in unused_files {
let rel = relative_path(&f.path, root);
let mut components = rel.components();
let first = components
.next()
.map(|c| c.as_os_str().to_string_lossy().to_string());
if first.as_deref() == Some(dom_dir.as_str()) {
let sub_key = components.next().map_or_else(
|| dom_dir.clone(),
|c| format!("{}/{}", dom_dir, c.as_os_str().to_string_lossy()),
);
if let Some(&idx) = sub_map.get(&sub_key) {
sub_counts[idx].1 += 1;
} else {
sub_map.insert(sub_key.clone(), sub_counts.len());
sub_counts.push((sub_key, 1, true));
}
}
}
sub_counts.sort_by_key(|b| std::cmp::Reverse(b.1));
let mut combined = sub_counts;
for entry in &dir_counts {
if entry.0 != *dom_dir {
combined.push(entry.clone());
}
}
combined
} else {
dir_counts.clone()
};
let shown = display_entries.len().min(MAX_FLAT_ITEMS);
for (dir, count, is_dir) in &display_entries[..shown] {
let label = if *is_dir {
format!("{dir}/").bold().to_string()
} else {
dir.dimmed().to_string()
};
lines.push(format!(" {} {} file{}", label, count, plural(*count)));
}
if display_entries.len() > MAX_FLAT_ITEMS {
let remaining = display_entries.len() - MAX_FLAT_ITEMS;
let hint = if remaining > SCOPING_HINT_THRESHOLD || total_issues > SCOPING_HINT_THRESHOLD {
format!(
"... and {remaining} more director{} \u{2014} try --workspace <name> or --changed-since main to scope",
if remaining == 1 { "y" } else { "ies" }
)
} else {
format!(
"... and {remaining} more director{} (--format json for full list)",
if remaining == 1 { "y" } else { "ies" }
)
};
lines.push(format!(" {}", hint.dimmed()));
}
push_section_footer_rollup(lines, title, unused_files.len());
lines.push(String::new());
}
fn build_human_section_ex<T>(
lines: &mut Vec<String>,
items: &[T],
title: &str,
level: Level,
max: usize,
total_issues: usize,
format_lines: impl Fn(&T) -> Vec<String>,
) {
if items.is_empty() {
return;
}
lines.push(build_section_header(title, items.len(), level));
let shown = items.len().min(max);
for item in &items[..shown] {
for line in format_lines(item) {
lines.push(line);
}
}
if items.len() > max {
let remaining = items.len() - max;
lines.push(format!(
" {}",
truncation_hint(remaining, total_issues).dimmed()
));
}
push_section_footer_with_count(lines, title, items.len());
lines.push(String::new());
}
#[expect(
clippy::too_many_arguments,
reason = "section renderer needs all display parameters"
)]
fn build_human_grouped_section<'a, T>(
lines: &mut Vec<String>,
items: &'a [T],
title: &str,
level: Level,
root: &Path,
max_files: usize,
get_path: impl Fn(&'a T) -> &'a Path,
format_detail: &impl Fn(&T) -> String,
) {
if items.is_empty() {
return;
}
lines.push(build_section_header(title, items.len(), level));
build_grouped_by_file(
lines,
items,
root,
get_path,
format_detail,
max_files,
MAX_ITEMS_PER_FILE,
);
push_section_footer_with_count(lines, title, items.len());
lines.push(String::new());
}
fn build_duplicate_exports_section(
lines: &mut Vec<String>,
items: &[fallow_core::results::DuplicateExport],
level: Level,
root: &Path,
total_issues: usize,
) {
if items.is_empty() {
return;
}
let title = "Duplicate exports";
lines.push(build_section_header(title, items.len(), level));
let mut pair_groups: Vec<(String, String, Vec<&str>)> = Vec::new();
let mut pair_map: rustc_hash::FxHashMap<(String, String), usize> =
rustc_hash::FxHashMap::default();
for dup in items {
if dup.locations.len() < 2 {
continue;
}
let mut paths: Vec<String> = dup
.locations
.iter()
.map(|loc| relative_path(&loc.path, root).display().to_string())
.collect();
paths.sort();
paths.dedup();
let key = (paths[0].clone(), paths.get(1).cloned().unwrap_or_default());
if let Some(&group_idx) = pair_map.get(&key) {
pair_groups[group_idx].2.push(&dup.export_name);
} else {
pair_map.insert(key, pair_groups.len());
pair_groups.push((
paths[0].clone(),
paths.get(1).cloned().unwrap_or_default(),
vec![&dup.export_name],
));
}
}
pair_groups.sort_by_key(|b| std::cmp::Reverse(b.2.len()));
let shown = pair_groups.len().min(MAX_FLAT_ITEMS);
for (file_a, file_b, exports) in &pair_groups[..shown] {
let export_list = if exports.len() <= 5 {
exports
.iter()
.map(|e| e.bold().to_string())
.collect::<Vec<_>>()
.join(", ")
} else {
let mut display: Vec<String> =
exports[..5].iter().map(|e| e.bold().to_string()).collect();
display.push(format!("... +{}", exports.len() - 5).dimmed().to_string());
display.join(", ")
};
let elided_b = elide_common_prefix(file_a, file_b);
lines.push(format!(" {}", format_path(file_a)));
lines.push(format!(
" {} {} ({} export{})",
"\u{2194}".dimmed(),
format_path(elided_b),
exports.len(),
plural(exports.len())
));
lines.push(format!(" {export_list}"));
lines.push(String::new());
}
if pair_groups.len() > MAX_FLAT_ITEMS {
let remaining = pair_groups.len() - MAX_FLAT_ITEMS;
lines.push(format!(
" {}",
truncation_hint(remaining, total_issues).dimmed()
));
}
push_section_footer_with_count(lines, title, items.len());
lines.push(String::new());
}
fn build_circular_deps_section(
lines: &mut Vec<String>,
items: &[fallow_core::results::CircularDependency],
level: Level,
root: &Path,
total_issues: usize,
) {
if items.is_empty() {
return;
}
let title = "Circular dependencies";
lines.push(build_section_header(title, items.len(), level));
let mut hub_groups: Vec<(String, Vec<&fallow_core::results::CircularDependency>)> = Vec::new();
let mut hub_map: rustc_hash::FxHashMap<String, usize> = rustc_hash::FxHashMap::default();
for cycle in items {
let hub = cycle
.files
.first()
.map(|p| relative_path(p, root).display().to_string())
.unwrap_or_default();
if let Some(&idx) = hub_map.get(&hub) {
hub_groups[idx].1.push(cycle);
} else {
hub_map.insert(hub.clone(), hub_groups.len());
hub_groups.push((hub, vec![cycle]));
}
}
hub_groups.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(&b.0)));
let shown = hub_groups.len().min(MAX_FLAT_ITEMS);
for (hub_path, cycles) in &hub_groups[..shown] {
let count_tag = if cycles.len() > 1 {
format!(" ({} cycles)", cycles.len()).dimmed().to_string()
} else {
String::new()
};
lines.push(format!(" {}{}", format_path(hub_path), count_tag));
for cycle in cycles {
let rel_paths: Vec<String> = cycle
.files
.iter()
.map(|p| relative_path(p, root).display().to_string())
.collect();
let mut chain_parts: Vec<String> = Vec::new();
for path in &rel_paths[1..] {
let elided = elide_common_prefix(hub_path, path);
chain_parts.push(format_path(elided));
}
let (_, hub_filename) = split_dir_filename(hub_path);
chain_parts.push(hub_filename.bold().to_string());
let type_only_tag = if cycle
.files
.iter()
.all(|p| p.to_str().is_some_and(|s| s.ends_with(".d.ts")))
{
format!(" {}", "(type-only)".dimmed())
} else {
String::new()
};
let cross_pkg_tag = if cycle.is_cross_package {
format!(" {}", "(cross-package)".dimmed())
} else {
String::new()
};
lines.push(format!(
" {} {}{}{}",
"\u{2192}".dimmed(),
chain_parts.join(&format!(" {} ", "\u{2192}".dimmed())),
type_only_tag,
cross_pkg_tag,
));
}
lines.push(String::new());
}
if hub_groups.len() > MAX_FLAT_ITEMS {
let hidden: usize = hub_groups[MAX_FLAT_ITEMS..]
.iter()
.map(|(_, cycles)| cycles.len())
.sum();
lines.push(format!(
" {}",
truncation_hint(hidden, total_issues).dimmed()
));
lines.push(String::new());
}
push_section_footer_with_count(lines, title, items.len());
if !lines.last().is_some_and(String::is_empty) {
lines.push(String::new());
}
}
fn build_boundary_violations_section(
lines: &mut Vec<String>,
items: &[fallow_core::results::BoundaryViolation],
level: Level,
root: &Path,
total_issues: usize,
) {
if items.is_empty() {
return;
}
let title = "Boundary violations";
lines.push(build_section_header(title, items.len(), level));
let shown = items.len().min(MAX_FLAT_ITEMS);
for v in &items[..shown] {
let from = relative_path(&v.from_path, root).display().to_string();
let to = relative_path(&v.to_path, root).display().to_string();
lines.push(format!(
" {}:{} {} {} {} {}",
from,
v.line,
"\u{2192}".dimmed(),
to,
format!("({}", v.from_zone).dimmed(),
format!("\u{2192} {})", v.to_zone).dimmed(),
));
}
if items.len() > MAX_FLAT_ITEMS {
let remaining = items.len() - MAX_FLAT_ITEMS;
lines.push(format!(
" {}",
truncation_hint(remaining, total_issues).dimmed()
));
}
push_section_footer_with_count(lines, title, items.len());
lines.push(String::new());
}
fn build_stale_suppressions_section(
lines: &mut Vec<String>,
items: &[fallow_core::results::StaleSuppression],
level: Level,
root: &Path,
total_issues: usize,
) {
if items.is_empty() {
return;
}
let title = "Stale suppressions";
lines.push(build_section_header(title, items.len(), level));
let shown = items.len().min(MAX_FLAT_ITEMS);
for s in &items[..shown] {
let path_str = relative_path(&s.path, root).display().to_string();
lines.push(format!(
" {}:{}:{} {} {}",
path_str,
s.line,
s.col,
s.description().bold(),
format!("({})", s.explanation()).dimmed(),
));
}
if items.len() > MAX_FLAT_ITEMS {
let remaining = items.len() - MAX_FLAT_ITEMS;
lines.push(format!(
" {}",
truncation_hint(remaining, total_issues).dimmed()
));
}
push_section_footer_with_count(lines, title, items.len());
lines.push(String::new());
}
fn collect_matching_rules(
results: &AnalysisResults,
root: &Path,
resolver: &OwnershipResolver,
) -> Vec<String> {
let mut rules: FxHashSet<String> = FxHashSet::default();
let mut check = |path: &Path| {
if let (_, Some(rule)) = resolver.resolve_with_rule(relative_path(path, root)) {
rules.insert(rule);
}
};
for f in &results.unused_files {
check(&f.path);
}
for e in &results.unused_exports {
check(&e.path);
}
for e in &results.unused_types {
check(&e.path);
}
for m in &results.unused_enum_members {
check(&m.path);
}
for m in &results.unused_class_members {
check(&m.path);
}
for u in &results.unresolved_imports {
check(&u.path);
}
for c in &results.circular_dependencies {
if let Some(first) = c.files.first() {
check(first);
}
}
for b in &results.boundary_violations {
check(&b.from_path);
}
for s in &results.stale_suppressions {
check(&s.path);
}
let mut sorted: Vec<String> = rules.into_iter().collect();
sorted.sort();
sorted.truncate(3);
sorted
}
pub(in crate::report) fn print_grouped_human(
groups: &[crate::report::grouping::ResultGroup],
root: &Path,
rules: &RulesConfig,
elapsed: Duration,
quiet: bool,
resolver: Option<&OwnershipResolver>,
) {
if !quiet {
eprintln!();
}
let mut group_counts: Vec<(&str, usize)> = groups
.iter()
.map(|g| (g.key.as_str(), g.results.total_issues()))
.filter(|(_, count)| *count > 0)
.collect();
group_counts.sort_by_key(|b| std::cmp::Reverse(b.1));
if !group_counts.is_empty() {
let summary_parts: Vec<String> = group_counts
.iter()
.map(|(key, count)| format!("{key} {count}"))
.collect();
let summary = format!(
"{} group{}: {}",
group_counts.len(),
plural(group_counts.len()),
summary_parts.join(" \u{00b7} ")
);
println!("{}", summary.dimmed());
println!();
}
let mut grand_total: usize = 0;
let mut seen_footers: FxHashSet<String> = FxHashSet::default();
for group in groups {
let total = group.results.total_issues();
if total == 0 {
continue;
}
grand_total += total;
let issue_word = if total == 1 { "issue" } else { "issues" };
let breakdown = build_summary_footer(&group.results, 0, 0);
let header_text = if breakdown.is_empty() {
format!("{} ({total} {issue_word})", group.key)
} else {
format!("{} ({total} {issue_word}: {breakdown})", group.key)
};
let header_text = match resolver {
Some(r @ OwnershipResolver::Owner(_)) => {
let matched = collect_matching_rules(&group.results, root, r);
if matched.is_empty() {
header_text
} else {
format!("{header_text} \u{2014} matched by {}", matched.join(", "))
}
}
_ => header_text,
};
println!("{}", header_text.cyan().bold());
let lines = build_human_lines(&group.results, root, rules, None);
for line in &lines {
if line.contains("docs.fallow.tools") && !seen_footers.insert(line.clone()) {
continue;
}
println!("{line}");
}
if group.key == crate::codeowners::UNOWNED_LABEL {
eprintln!(
" {}",
"Files with no CODEOWNERS entry \u{2014} add ownership or verify before removing"
.dimmed()
);
eprintln!();
}
}
if !quiet {
if grand_total == 0 {
eprintln!(
"{}",
format!("\u{2713} No issues found ({:.2}s)", elapsed.as_secs_f64())
.green()
.bold()
);
} else {
eprintln!(
"{}",
format!(
"\u{2717} {grand_total} issue{} across {} group{} ({:.2}s)",
plural(grand_total),
groups
.iter()
.filter(|g| g.results.total_issues() > 0)
.count(),
plural(
groups
.iter()
.filter(|g| g.results.total_issues() > 0)
.count()
),
elapsed.as_secs_f64()
)
.red()
.bold()
);
}
}
}
fn emit_config_quality_signal(results: &AnalysisResults, root: &Path) {
if results.unused_files.len() <= 50 {
return;
}
let mut dir_counts: rustc_hash::FxHashMap<String, usize> = rustc_hash::FxHashMap::default();
for f in &results.unused_files {
let rel = relative_path(&f.path, root);
if let Some(first) = rel.components().next() {
*dir_counts
.entry(first.as_os_str().to_string_lossy().to_string())
.or_insert(0) += 1;
}
}
let total = results.unused_files.len();
if let Some((dominant_dir, count)) = dir_counts.iter().max_by_key(|(_, c)| **c) {
let pct = (*count as f64 / total as f64) * 100.0;
if pct > 80.0 {
let is_source_dir =
matches!(dominant_dir.as_str(), "packages" | "src" | "lib" | "apps");
let advice = if is_source_dir {
format!(
"Note: {pct:.0}% of unused files are under {dominant_dir}/ \
\u{2014} run `fallow list --entry-points` to verify entry-point detection \
\u{2014} https://docs.fallow.tools/explanations/dead-code#unused-files"
)
} else {
format!(
"Note: {pct:.0}% of unused files are under {dominant_dir}/ \
\u{2014} consider adding it to ignorePatterns or using --production \
(analyzes only production entry points) \
\u{2014} https://docs.fallow.tools/explanations/dead-code#unused-files"
)
};
eprintln!(" {}", advice.yellow());
}
}
}
fn build_summary_footer(
results: &AnalysisResults,
suppressed_exports: usize,
suppressed_types: usize,
) -> String {
let mut parts = Vec::new();
let mut add = |count: usize, label: &str| {
if count > 0 {
let display_label = if count == 1 && label.ends_with("ies") {
format!("{}y", &label[..label.len() - 3])
} else if count == 1 && label.ends_with('s') {
label[..label.len() - 1].to_string()
} else {
label.to_string()
};
let mut s = String::new();
let _ = write!(s, "{count} {display_label}");
if count != 1 && !label.ends_with('s') {
s.push('s');
}
parts.push(s);
}
};
add(results.unused_files.len(), "file");
add(
results
.unused_exports
.len()
.saturating_sub(suppressed_exports),
"export",
);
add(
results.unused_types.len().saturating_sub(suppressed_types),
"type",
);
add(results.unused_dependencies.len(), "unused dependencies");
add(
results.unused_dev_dependencies.len() + results.unused_optional_dependencies.len(),
"dev/optional dependencies",
);
add(results.unused_enum_members.len(), "enum members");
add(results.unused_class_members.len(), "class members");
add(results.unresolved_imports.len(), "unresolved imports");
add(results.unlisted_dependencies.len(), "unlisted dependencies");
{
let mut pair_set = rustc_hash::FxHashSet::default();
for dup in &results.duplicate_exports {
if dup.locations.len() >= 2 {
let mut paths: Vec<&std::path::Path> =
dup.locations.iter().map(|l| l.path.as_path()).collect();
paths.sort();
paths.dedup();
if paths.len() >= 2 {
pair_set.insert((paths[0].to_path_buf(), paths[1].to_path_buf()));
}
}
}
add(pair_set.len(), "duplicate pair");
}
add(
results.type_only_dependencies.len(),
"type-only dependencies",
);
add(
results.test_only_dependencies.len(),
"test-only dependencies",
);
add(results.circular_dependencies.len(), "circular dependencies");
add(results.boundary_violations.len(), "violations");
add(results.stale_suppressions.len(), "stale suppressions");
parts.join(" \u{00b7} ")
}
pub(in crate::report) fn print_check_summary(
results: &AnalysisResults,
rules: &RulesConfig,
elapsed: Duration,
quiet: bool,
) {
let total = results.total_issues();
if total == 0 {
if !quiet {
eprintln!(
"{}",
format!("\u{2713} No issues found ({:.2}s)", elapsed.as_secs_f64())
.green()
.bold()
);
}
return;
}
println!("{}", "Dead Code Summary".bold());
println!();
let categories: &[(&str, usize, Level)] = &[
(
"Unused files",
results.unused_files.len(),
severity_to_level(rules.unused_files),
),
(
"Unused exports",
results.unused_exports.len(),
severity_to_level(rules.unused_exports),
),
(
"Unused types",
results.unused_types.len(),
severity_to_level(rules.unused_types),
),
(
"Unused dependencies",
results.unused_dependencies.len(),
severity_to_level(rules.unused_dependencies),
),
(
"Unused dev dependencies",
results.unused_dev_dependencies.len(),
severity_to_level(rules.unused_dev_dependencies),
),
(
"Unused optional dependencies",
results.unused_optional_dependencies.len(),
severity_to_level(rules.unused_optional_dependencies),
),
(
"Unused enum members",
results.unused_enum_members.len(),
severity_to_level(rules.unused_enum_members),
),
(
"Unused class members",
results.unused_class_members.len(),
severity_to_level(rules.unused_class_members),
),
(
"Unresolved imports",
results.unresolved_imports.len(),
severity_to_level(rules.unresolved_imports),
),
(
"Unlisted dependencies",
results.unlisted_dependencies.len(),
severity_to_level(rules.unlisted_dependencies),
),
(
"Duplicate exports",
results.duplicate_exports.len(),
severity_to_level(rules.duplicate_exports),
),
(
"Type-only dependencies",
results.type_only_dependencies.len(),
severity_to_level(rules.type_only_dependencies),
),
(
"Test-only dependencies",
results.test_only_dependencies.len(),
severity_to_level(rules.test_only_dependencies),
),
(
"Circular dependencies",
results.circular_dependencies.len(),
severity_to_level(rules.circular_dependencies),
),
(
"Boundary violations",
results.boundary_violations.len(),
severity_to_level(rules.boundary_violation),
),
(
"Stale suppressions",
results.stale_suppressions.len(),
severity_to_level(rules.stale_suppressions),
),
];
for (name, count, level) in categories {
if *count == 0 {
continue;
}
let count_str = format!("{count:>6}");
let colored = match level {
Level::Error => count_str.red().bold().to_string(),
Level::Warn => count_str.yellow().to_string(),
Level::Info => count_str.dimmed().to_string(),
};
println!(" {colored} {name}");
}
println!();
let total_str = format!("{total:>6}");
println!(" {} {}", total_str.bold(), "Total".bold());
if !quiet {
eprintln!(
"{}",
format!("\u{2717} {total} issues ({:.2}s)", elapsed.as_secs_f64())
.red()
.bold()
);
}
}
#[cfg(test)]
mod tests {
use super::super::{plain, strip_ansi};
use super::*;
use fallow_config::{RulesConfig, Severity};
use fallow_core::extract::MemberKind;
use fallow_core::results::*;
use std::path::PathBuf;
fn sample_results(root: &Path) -> AnalysisResults {
crate::report::test_helpers::sample_results(root)
}
#[test]
fn empty_results_produce_no_lines() {
let root = PathBuf::from("/project");
let results = AnalysisResults::default();
let rules = RulesConfig::default();
let lines = build_human_lines(&results, &root, &rules, None);
assert!(lines.is_empty());
}
#[test]
fn section_headers_contain_title_and_count() {
let root = PathBuf::from("/project");
let results = sample_results(&root);
let rules = RulesConfig::default();
let lines = build_human_lines(&results, &root, &rules, None);
let text = plain(&lines);
assert!(text.contains("Unused files (1)"));
assert!(text.contains("Unused exports (1)"));
assert!(text.contains("Unused type exports (1)"));
assert!(text.contains("Unused dependencies (1)"));
assert!(text.contains("Unused devDependencies (1)"));
assert!(text.contains("Unused optionalDependencies (1)"));
assert!(text.contains("Unused enum members (1)"));
assert!(text.contains("Unused class members (1)"));
assert!(text.contains("Unresolved imports (1)"));
assert!(text.contains("Unlisted dependencies (1)"));
assert!(text.contains("Duplicate exports (1)"));
assert!(text.contains("Type-only dependencies (consider moving to devDependencies) (1)"));
assert!(text.contains("Circular dependencies (1)"));
}
#[test]
fn section_header_shows_correct_count_for_multiple_items() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
for i in 0..5 {
results.unused_files.push(UnusedFile {
path: root.join(format!("src/dead{i}.ts")),
});
}
let rules = RulesConfig::default();
let lines = build_human_lines(&results, &root, &rules, None);
let text = plain(&lines);
assert!(text.contains("Unused files (5)"));
}
#[test]
fn unused_files_show_relative_paths() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results.unused_files.push(UnusedFile {
path: root.join("src/components/Button.tsx"),
});
let rules = RulesConfig::default();
let lines = build_human_lines(&results, &root, &rules, None);
let text = plain(&lines);
assert!(text.contains("src/components/Button.tsx"));
assert!(!text.contains("/project/"));
}
#[test]
fn unused_exports_grouped_by_file_with_line_and_name() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results.unused_exports.push(UnusedExport {
path: root.join("src/utils.ts"),
export_name: "helperFn".to_string(),
is_type_only: false,
line: 10,
col: 4,
span_start: 120,
is_re_export: false,
});
results.unused_exports.push(UnusedExport {
path: root.join("src/utils.ts"),
export_name: "anotherFn".to_string(),
is_type_only: false,
line: 25,
col: 0,
span_start: 300,
is_re_export: false,
});
let rules = RulesConfig::default();
let lines = build_human_lines(&results, &root, &rules, None);
let text = plain(&lines);
assert!(text.contains("Unused exports (2)"));
assert!(text.contains("src/utils.ts"));
assert!(text.contains(":10 helperFn"));
assert!(text.contains(":25 anotherFn"));
}
#[test]
fn re_exports_are_tagged() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results.unused_exports.push(UnusedExport {
path: root.join("src/index.ts"),
export_name: "reExported".to_string(),
is_type_only: false,
line: 1,
col: 0,
span_start: 0,
is_re_export: true,
});
let rules = RulesConfig::default();
let lines = build_human_lines(&results, &root, &rules, None);
let text = plain(&lines);
assert!(text.contains("(re-export)"));
}
#[test]
fn non_re_exports_have_no_tag() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results.unused_exports.push(UnusedExport {
path: root.join("src/utils.ts"),
export_name: "helper".to_string(),
is_type_only: false,
line: 1,
col: 0,
span_start: 0,
is_re_export: false,
});
let rules = RulesConfig::default();
let lines = build_human_lines(&results, &root, &rules, None);
let text = plain(&lines);
assert!(!text.contains("(re-export)"));
}
#[test]
fn unused_enum_members_show_parent_dot_member() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results.unused_enum_members.push(UnusedMember {
path: root.join("src/enums.ts"),
parent_name: "Color".to_string(),
member_name: "Purple".to_string(),
kind: MemberKind::EnumMember,
line: 5,
col: 2,
});
let rules = RulesConfig::default();
let lines = build_human_lines(&results, &root, &rules, None);
let text = plain(&lines);
assert!(text.contains("Color.Purple"));
assert!(text.contains(":5"));
}
#[test]
fn unused_class_members_show_parent_dot_member() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results.unused_class_members.push(UnusedMember {
path: root.join("src/service.ts"),
parent_name: "ApiService".to_string(),
member_name: "disconnect".to_string(),
kind: MemberKind::ClassMethod,
line: 99,
col: 4,
});
let rules = RulesConfig::default();
let lines = build_human_lines(&results, &root, &rules, None);
let text = plain(&lines);
assert!(text.contains("ApiService.disconnect"));
assert!(text.contains(":99"));
}
#[test]
fn unused_deps_at_root_show_package_name_only() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results.unused_dependencies.push(UnusedDependency {
package_name: "lodash".to_string(),
location: DependencyLocation::Dependencies,
path: root.join("package.json"),
line: 5,
});
let rules = RulesConfig::default();
let lines = build_human_lines(&results, &root, &rules, None);
let text = plain(&lines);
assert!(text.contains("lodash"));
assert!(!text.contains("(package.json)"));
}
#[test]
fn unused_deps_in_workspace_show_workspace_path() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results.unused_dependencies.push(UnusedDependency {
package_name: "axios".to_string(),
location: DependencyLocation::Dependencies,
path: root.join("packages/web/package.json"),
line: 8,
});
let rules = RulesConfig::default();
let lines = build_human_lines(&results, &root, &rules, None);
let text = plain(&lines);
assert!(text.contains("axios"));
assert!(text.contains("(packages/web/package.json)"));
}
#[test]
fn unresolved_imports_show_specifier_and_line() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results.unresolved_imports.push(UnresolvedImport {
path: root.join("src/app.ts"),
specifier: "@org/missing-pkg".to_string(),
line: 7,
col: 0,
specifier_col: 0,
});
let rules = RulesConfig::default();
let lines = build_human_lines(&results, &root, &rules, None);
let text = plain(&lines);
assert!(text.contains("src/app.ts"));
assert!(text.contains(":7"));
assert!(text.contains("@org/missing-pkg"));
}
#[test]
fn duplicate_exports_show_name_and_locations() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results.duplicate_exports.push(DuplicateExport {
export_name: "Config".to_string(),
locations: vec![
DuplicateLocation {
path: root.join("src/config.ts"),
line: 15,
col: 0,
},
DuplicateLocation {
path: root.join("src/types.ts"),
line: 30,
col: 0,
},
],
});
let rules = RulesConfig::default();
let lines = build_human_lines(&results, &root, &rules, None);
let text = plain(&lines);
assert!(text.contains("Config"));
assert!(text.contains("src/config.ts"));
assert!(text.contains("types.ts"));
}
#[test]
fn circular_dependencies_show_cycle_with_arrow_and_repeat() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results.circular_dependencies.push(CircularDependency {
files: vec![
root.join("src/a.ts"),
root.join("src/b.ts"),
root.join("src/c.ts"),
],
length: 3,
line: 1,
col: 0,
is_cross_package: false,
});
let rules = RulesConfig::default();
let lines = build_human_lines(&results, &root, &rules, None);
let text = plain(&lines);
assert!(text.contains("a.ts"));
assert!(text.contains("b.ts"));
assert!(text.contains("c.ts"));
assert!(text.contains("\u{2192}"));
}
#[test]
fn empty_sections_are_omitted() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results.unused_files.push(UnusedFile {
path: root.join("src/dead.ts"),
});
let rules = RulesConfig::default();
let lines = build_human_lines(&results, &root, &rules, None);
let text = plain(&lines);
assert!(text.contains("Unused files (1)"));
assert!(!text.contains("Unused exports"));
assert!(!text.contains("Unused dependencies"));
assert!(!text.contains("Unresolved imports"));
}
#[test]
fn section_header_uses_bullet_indicator() {
let header = build_section_header("Test section", 3, Level::Error);
let text = strip_ansi(&header);
assert!(text.contains("\u{25cf}"));
assert!(text.contains("Test section (3)"));
}
#[test]
fn section_header_formats_for_all_levels() {
for level in [Level::Error, Level::Warn, Level::Info] {
let header = build_section_header("Items", 7, level);
let text = strip_ansi(&header);
assert!(
text.contains("Items (7)"),
"Missing title for level {level:?}"
);
}
}
#[test]
fn grouped_exports_from_different_files_sorted_by_path() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results.unused_exports.push(UnusedExport {
path: root.join("src/z-file.ts"),
export_name: "zExport".to_string(),
is_type_only: false,
line: 1,
col: 0,
span_start: 0,
is_re_export: false,
});
results.unused_exports.push(UnusedExport {
path: root.join("src/a-file.ts"),
export_name: "aExport".to_string(),
is_type_only: false,
line: 1,
col: 0,
span_start: 0,
is_re_export: false,
});
let rules = RulesConfig::default();
let lines = build_human_lines(&results, &root, &rules, None);
let text = plain(&lines);
let a_pos = text.find("src/a-file.ts").unwrap();
let z_pos = text.find("src/z-file.ts").unwrap();
assert!(a_pos < z_pos, "Files should be sorted alphabetically");
}
#[test]
fn grouped_items_from_same_file_share_one_file_header() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
for i in 0..3 {
results.unused_exports.push(UnusedExport {
path: root.join("src/utils.ts"),
export_name: format!("fn{i}"),
is_type_only: false,
line: (i + 1) as u32,
col: 0,
span_start: 0,
is_re_export: false,
});
}
let rules = RulesConfig::default();
let lines = build_human_lines(&results, &root, &rules, None);
let text = plain(&lines);
let count = text.matches("src/utils.ts").count();
assert_eq!(count, 1, "File header should appear once, found {count}");
}
#[test]
fn off_severity_still_shows_section_when_items_present() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results.unused_files.push(UnusedFile {
path: root.join("src/dead.ts"),
});
let rules = RulesConfig {
unused_files: Severity::Off,
..RulesConfig::default()
};
let lines = build_human_lines(&results, &root, &rules, None);
let text = plain(&lines);
assert!(text.contains("Unused files (1)"));
}
#[test]
fn deeply_nested_paths_display_correctly() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results.unused_files.push(UnusedFile {
path: root.join("packages/ui/src/components/forms/inputs/TextInput.tsx"),
});
let rules = RulesConfig::default();
let lines = build_human_lines(&results, &root, &rules, None);
let text = plain(&lines);
assert!(text.contains("packages/ui/src/components/forms/inputs/TextInput.tsx"));
}
#[test]
fn all_issue_types_produce_output_lines() {
let root = PathBuf::from("/project");
let results = sample_results(&root);
let rules = RulesConfig::default();
let lines = build_human_lines(&results, &root, &rules, None);
let text = plain(&lines);
assert!(text.contains("Unused files (1)"));
assert!(text.contains("Unused exports (1)"));
assert!(text.contains("Unused type exports (1)"));
assert!(text.contains("Unused dependencies (1)"));
assert!(text.contains("Unused devDependencies (1)"));
assert!(text.contains("Unused optionalDependencies (1)"));
assert!(text.contains("Unused enum members (1)"));
assert!(text.contains("Unused class members (1)"));
assert!(text.contains("Unresolved imports (1)"));
assert!(text.contains("Unlisted dependencies (1)"));
assert!(text.contains("Duplicate exports (1)"));
assert!(text.contains("Type-only dependencies (consider moving to devDependencies) (1)"));
assert!(text.contains(
"Test-only production dependencies (consider moving to devDependencies) (1)"
));
assert!(text.contains("Circular dependencies (1)"));
}
#[test]
fn each_section_ends_with_empty_line_separator() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results.unused_files.push(UnusedFile {
path: root.join("src/a.ts"),
});
results.unused_dependencies.push(UnusedDependency {
package_name: "pkg".to_string(),
location: DependencyLocation::Dependencies,
path: root.join("package.json"),
line: 1,
});
let rules = RulesConfig::default();
let lines = build_human_lines(&results, &root, &rules, None);
let empty_count = lines.iter().filter(|l| l.is_empty()).count();
assert_eq!(
empty_count, 4,
"Expected 4 empty separators (2 category headers + 2 sections), got {empty_count}"
);
}
#[test]
fn type_only_deps_section_title_includes_suggestion() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results.type_only_dependencies.push(TypeOnlyDependency {
package_name: "zod".to_string(),
path: root.join("package.json"),
line: 8,
});
let rules = RulesConfig::default();
let lines = build_human_lines(&results, &root, &rules, None);
let text = plain(&lines);
assert!(text.contains("Type-only dependencies (consider moving to devDependencies)"));
}
#[test]
fn warn_severity_produces_header_with_bullet() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results.type_only_dependencies.push(TypeOnlyDependency {
package_name: "zod".to_string(),
path: root.join("package.json"),
line: 8,
});
let rules = RulesConfig::default();
let lines = build_human_lines(&results, &root, &rules, None);
let text = plain(&lines);
assert!(text.contains("\u{25cf}"));
assert!(text.contains("Type-only dependencies"));
}
#[test]
fn unlisted_deps_show_package_name() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results.unlisted_dependencies.push(UnlistedDependency {
package_name: "@scope/unknown-pkg".to_string(),
imported_from: vec![],
});
let rules = RulesConfig::default();
let lines = build_human_lines(&results, &root, &rules, None);
let text = plain(&lines);
assert!(text.contains("@scope/unknown-pkg"));
}
#[test]
fn circular_deps_grouped_by_hub() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results.circular_dependencies.push(CircularDependency {
files: vec![root.join("src/hub.ts"), root.join("src/a.ts")],
length: 2,
line: 1,
col: 0,
is_cross_package: false,
});
results.circular_dependencies.push(CircularDependency {
files: vec![root.join("src/hub.ts"), root.join("src/b.ts")],
length: 2,
line: 5,
col: 0,
is_cross_package: false,
});
let rules = RulesConfig::default();
let lines = build_human_lines(&results, &root, &rules, None);
let text = plain(&lines);
assert!(text.contains("(2 cycles)"));
assert_eq!(text.matches("hub.ts").count(), 3); }
#[test]
fn summary_footer_uses_short_labels() {
let root = PathBuf::from("/project");
let results = sample_results(&root);
let footer = build_summary_footer(&results, 0, 0);
assert!(footer.contains("1 file"));
assert!(footer.contains("1 export"));
assert!(footer.contains("1 circular"));
assert!(!footer.contains("unused file"));
}
#[test]
fn summary_footer_singularizes_pre_pluralized_labels_for_count_1() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results
.unused_enum_members
.push(fallow_core::results::UnusedMember {
path: root.join("src/types.ts"),
parent_name: "Status".to_string(),
member_name: "Unused".to_string(),
line: 10,
col: 0,
kind: MemberKind::EnumMember,
});
results
.unused_class_members
.push(fallow_core::results::UnusedMember {
path: root.join("src/foo.ts"),
parent_name: "Foo".to_string(),
member_name: "bar".to_string(),
line: 5,
col: 0,
kind: MemberKind::ClassMethod,
});
let footer = build_summary_footer(&results, 0, 0);
assert!(
footer.contains("1 enum member"),
"Expected '1 enum member' but got: {footer}"
);
assert!(
!footer.contains("1 enum members"),
"Should not contain '1 enum members': {footer}"
);
assert!(
footer.contains("1 class member"),
"Expected '1 class member' but got: {footer}"
);
assert!(
!footer.contains("1 class members"),
"Should not contain '1 class members': {footer}"
);
}
#[test]
fn section_footer_contains_docs_link() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
results.unused_files.push(UnusedFile {
path: root.join("src/dead.ts"),
});
let rules = RulesConfig::default();
let lines = build_human_lines(&results, &root, &rules, None);
let text = plain(&lines);
assert!(text.contains("docs.fallow.tools/explanations/dead-code"));
assert!(text.contains("Files not reachable from any entry point"));
}
#[test]
fn flat_section_truncates_at_max() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
for i in 0..15 {
results.unused_files.push(UnusedFile {
path: root.join(format!("src/dead{i}.ts")),
});
}
let rules = RulesConfig::default();
let lines = build_human_lines(&results, &root, &rules, None);
let text = plain(&lines);
assert!(text.contains("... and 5 more"));
}
#[test]
fn grouped_section_truncates_files() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
for i in 0..15 {
results.unused_exports.push(UnusedExport {
path: root.join(format!("src/file{i:02}.ts")),
export_name: format!("fn{i}"),
is_type_only: false,
line: 1,
col: 0,
span_start: 0,
is_re_export: false,
});
}
let rules = RulesConfig::default();
let lines = build_human_lines(&results, &root, &rules, None);
let text = plain(&lines);
assert!(text.contains("... and 5 more in 5 files"));
}
#[test]
fn top_flag_limits_unused_files_shown() {
let root = PathBuf::from("/project");
let mut results = AnalysisResults::default();
for i in 0..5 {
results.unused_files.push(UnusedFile {
path: root.join(format!("src/dead{i}.ts")),
});
}
let rules = RulesConfig::default();
let lines = build_human_lines(&results, &root, &rules, Some(2));
let text = plain(&lines);
assert!(text.contains("Unused files (5)"));
let file_lines: Vec<&str> = text
.lines()
.filter(|l| l.contains("src/dead") && l.contains(".ts"))
.collect();
assert_eq!(
file_lines.len(),
2,
"Expected 2 file lines with top=2, got {}: {file_lines:?}",
file_lines.len()
);
assert!(
text.contains("... and 3 more"),
"Expected truncation hint, got:\n{text}"
);
}
}