use std::path::Path;
use colored::Colorize;
use fallow_core::trace::{CloneTrace, DependencyTrace, ExportTrace, FileTrace};
use super::{plural, relative_path};
pub(in crate::report) fn print_export_trace_human(trace: &ExportTrace) {
print_lines(&build_export_trace_human_lines(trace));
}
pub(in crate::report) fn print_file_trace_human(trace: &FileTrace) {
print_lines(&build_file_trace_human_lines(trace));
}
pub(in crate::report) fn print_dependency_trace_human(trace: &DependencyTrace) {
print_lines(&build_dependency_trace_human_lines(trace));
}
pub(in crate::report) fn print_clone_trace_human(trace: &CloneTrace, root: &Path) {
print_lines(&build_clone_trace_human_lines(trace, root));
}
fn print_lines(lines: &[String]) {
for line in lines {
eprintln!("{line}");
}
}
fn build_export_trace_human_lines(trace: &ExportTrace) -> Vec<String> {
let mut lines = Vec::new();
lines.push(String::new());
let status_icon = if trace.is_used {
"USED".green().bold()
} else {
"UNUSED".red().bold()
};
lines.push(format!(
" {status_icon} {} in {}",
trace.export_name.bold(),
trace.file.display().to_string().dimmed()
));
lines.push(String::new());
let reachable = if trace.file_reachable {
"reachable".green()
} else {
"unreachable".red()
};
let entry = if trace.is_entry_point {
" (entry point)".cyan().to_string()
} else {
String::new()
};
lines.push(format!(" File: {reachable}{entry}"));
lines.push(format!(" Reason: {}", trace.reason));
if !trace.direct_references.is_empty() {
lines.push(String::new());
lines.push(format!(
" {} direct reference(s):",
trace.direct_references.len()
));
for r in &trace.direct_references {
lines.push(format!(
" {} {} ({})",
"->".dimmed(),
r.from_file.display(),
r.kind.dimmed()
));
}
}
if !trace.re_export_chains.is_empty() {
lines.push(String::new());
lines.push(" Re-exported through:".to_string());
for chain in &trace.re_export_chains {
lines.push(format!(
" {} {} as '{}' ({} ref(s))",
"->".dimmed(),
chain.barrel_file.display(),
chain.exported_as,
chain.reference_count
));
}
}
lines.push(String::new());
lines
}
fn build_file_trace_human_lines(trace: &FileTrace) -> Vec<String> {
let mut lines = Vec::new();
lines.push(String::new());
let reachable = if trace.is_reachable {
"REACHABLE".green().bold()
} else {
"UNREACHABLE".red().bold()
};
let entry = if trace.is_entry_point {
format!(" {}", "(entry point)".cyan())
} else {
String::new()
};
lines.push(format!(
" {reachable} {}{entry}",
trace.file.display().to_string().bold()
));
if !trace.exports.is_empty() {
lines.push(String::new());
lines.push(format!(" Exports ({}):", trace.exports.len()));
for export in &trace.exports {
let used_indicator = if export.reference_count > 0 {
format!("{} ref(s)", export.reference_count)
.green()
.to_string()
} else {
"unused".red().to_string()
};
let type_tag = if export.is_type_only {
" (type)".dimmed().to_string()
} else {
String::new()
};
lines.push(format!(
" {} {}{} [{}]",
"export".dimmed(),
export.name.bold(),
type_tag,
used_indicator
));
for r in &export.referenced_by {
lines.push(format!(
" {} {} ({})",
"->".dimmed(),
r.from_file.display(),
r.kind.dimmed()
));
}
}
}
if !trace.imports_from.is_empty() {
lines.push(String::new());
lines.push(format!(" Imports from ({}):", trace.imports_from.len()));
for path in &trace.imports_from {
lines.push(format!(" {} {}", "<-".dimmed(), path.display()));
}
}
if !trace.imported_by.is_empty() {
lines.push(String::new());
lines.push(format!(" Imported by ({}):", trace.imported_by.len()));
for path in &trace.imported_by {
lines.push(format!(" {} {}", "->".dimmed(), path.display()));
}
}
if !trace.re_exports.is_empty() {
lines.push(String::new());
lines.push(format!(" Re-exports ({}):", trace.re_exports.len()));
for re in &trace.re_exports {
lines.push(format!(
" {} '{}' as '{}' from {}",
"re-export".dimmed(),
re.imported_name,
re.exported_name,
re.source_file.display()
));
}
}
lines.push(String::new());
lines
}
fn build_dependency_trace_human_lines(trace: &DependencyTrace) -> Vec<String> {
let mut lines = Vec::new();
lines.push(String::new());
let status = if trace.is_used {
"USED".green().bold()
} else {
"UNUSED".red().bold()
};
lines.push(format!(
" {status} {} ({} import(s))",
trace.package_name.bold(),
trace.import_count
));
if !trace.imported_by.is_empty() {
lines.push(String::new());
lines.push(" Imported by:".to_string());
for path in &trace.imported_by {
let is_type_only = trace.type_only_imported_by.contains(path);
let tag = if is_type_only {
" (type-only)".dimmed().to_string()
} else {
String::new()
};
lines.push(format!(" {} {}{}", "->".dimmed(), path.display(), tag));
}
}
if trace.used_in_scripts {
lines.push(String::new());
lines.push(format!(
" {}",
"Referenced from package.json scripts or CI configs.".dimmed()
));
}
lines.push(String::new());
lines
}
fn build_clone_trace_human_lines(trace: &CloneTrace, root: &Path) -> Vec<String> {
let mut lines = Vec::new();
lines.push(String::new());
if let Some(ref matched) = trace.matched_instance {
let relative = relative_path(&matched.file, root);
lines.push(format!(
" {} clone at {}:{}-{}",
"FOUND".green().bold(),
relative.display(),
matched.start_line,
matched.end_line,
));
}
lines.push(format!(
" {} clone group(s) containing this location",
trace.clone_groups.len()
));
for (i, group) in trace.clone_groups.iter().enumerate() {
lines.push(String::new());
lines.push(format!(
" {} {} ({} lines, {} tokens, {} instance{})",
format!("Clone group {}", i + 1).bold(),
group.fingerprint.dimmed(),
group.line_count,
group.token_count,
group.instances.len(),
plural(group.instances.len())
));
for instance in &group.instances {
let relative = relative_path(&instance.file, root);
let is_queried = trace.matched_instance.as_ref().is_some_and(|m| {
m.file == instance.file
&& m.start_line == instance.start_line
&& m.end_line == instance.end_line
});
let marker = if is_queried {
">>".cyan()
} else {
"->".dimmed()
};
lines.push(format!(
" {} {}:{}-{}",
marker,
relative.display(),
instance.start_line,
instance.end_line
));
}
lines.push(format!(" {}", "Suggested refactor".bold()));
lines.push(format!(
" Extract function, saves ~{} line{}",
group.suggestion.estimated_savings,
plural(group.suggestion.estimated_savings),
));
if let Some(ref name) = group.suggested_name {
lines.push(format!(
" Proposed name: {} {}",
name,
"(best-effort, verify before applying)".dimmed(),
));
}
}
if let Some(ref matched) = trace.matched_instance {
lines.push(String::new());
lines.push(format!(" {}:", "Code fragment".dimmed()));
for (i, line) in matched.fragment.lines().enumerate() {
lines.push(format!(
" {} {}",
format!("{:>4}", matched.start_line + i).dimmed(),
line
));
}
}
lines.push(String::new());
lines.push(format!(
" {} {}",
"Docs:".dimmed(),
super::dupes::DOCS_DUPLICATION.dimmed()
));
lines.push(String::new());
lines
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use fallow_core::duplicates::{CloneInstance, RefactoringKind, RefactoringSuggestion};
use fallow_core::trace::{
CloneTrace, DependencyTrace, ExportReference, ExportTrace, FileTrace, ReExportChain,
TracedCloneGroup, TracedExport, TracedReExport,
};
use super::*;
fn plain(lines: &[String]) -> String {
lines
.iter()
.map(|line| super::super::strip_ansi(line))
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn export_trace_renders_reachability_references_and_barrels() {
let trace = ExportTrace {
file: PathBuf::from("src/lib.ts"),
export_name: "formatUser".to_string(),
file_reachable: true,
is_entry_point: true,
is_used: true,
direct_references: vec![ExportReference {
from_file: PathBuf::from("src/app.ts"),
kind: "value".to_string(),
}],
re_export_chains: vec![ReExportChain {
barrel_file: PathBuf::from("src/index.ts"),
exported_as: "formatUser".to_string(),
reference_count: 2,
}],
reason: "referenced from reachable code".to_string(),
};
let rendered = plain(&build_export_trace_human_lines(&trace));
assert!(rendered.contains("USED formatUser in src/lib.ts"));
assert!(rendered.contains("File: reachable (entry point)"));
assert!(rendered.contains("Reason: referenced from reachable code"));
assert!(rendered.contains("1 direct reference(s):"));
assert!(rendered.contains("-> src/app.ts (value)"));
assert!(rendered.contains("Re-exported through:"));
assert!(rendered.contains("-> src/index.ts as 'formatUser' (2 ref(s))"));
}
#[test]
fn file_trace_renders_exports_imports_dependents_and_re_exports() {
let trace = FileTrace {
file: PathBuf::from("src/model.ts"),
is_reachable: false,
is_entry_point: false,
exports: vec![TracedExport {
name: "User".to_string(),
is_type_only: true,
reference_count: 0,
referenced_by: Vec::new(),
}],
imports_from: vec![PathBuf::from("src/db.ts")],
imported_by: vec![PathBuf::from("src/app.ts")],
re_exports: vec![TracedReExport {
source_file: PathBuf::from("src/types.ts"),
imported_name: "Account".to_string(),
exported_name: "Account".to_string(),
}],
};
let rendered = plain(&build_file_trace_human_lines(&trace));
assert!(rendered.contains("UNREACHABLE src/model.ts"));
assert!(rendered.contains("Exports (1):"));
assert!(rendered.contains("export User (type) [unused]"));
assert!(rendered.contains("Imports from (1):"));
assert!(rendered.contains("<- src/db.ts"));
assert!(rendered.contains("Imported by (1):"));
assert!(rendered.contains("-> src/app.ts"));
assert!(rendered.contains("Re-exports (1):"));
assert!(rendered.contains("re-export 'Account' as 'Account' from src/types.ts"));
}
#[test]
fn dependency_trace_renders_type_only_imports_and_script_usage() {
let trace = DependencyTrace {
package_name: "zod".to_string(),
imported_by: vec![PathBuf::from("src/schema.ts")],
type_only_imported_by: vec![PathBuf::from("src/schema.ts")],
used_in_scripts: true,
is_used: true,
import_count: 1,
};
let rendered = plain(&build_dependency_trace_human_lines(&trace));
assert!(rendered.contains("USED zod (1 import(s))"));
assert!(rendered.contains("Imported by:"));
assert!(rendered.contains("-> src/schema.ts (type-only)"));
assert!(rendered.contains("Referenced from package.json scripts or CI configs."));
}
#[test]
fn clone_trace_renders_match_group_suggestion_fragment_and_docs() {
let root = PathBuf::from("/repo");
let matched = clone_instance("/repo/src/a.ts", 10, 12, "const x = 1;\nreturn x;");
let trace = CloneTrace {
file: PathBuf::from("/repo/src/a.ts"),
line: 10,
matched_instance: Some(matched.clone()),
clone_groups: vec![TracedCloneGroup {
fingerprint: "dup:abc123".to_string(),
token_count: 42,
line_count: 3,
instances: vec![
matched,
clone_instance("/repo/src/b.ts", 20, 22, "const x = 1;\nreturn x;"),
],
suggestion: RefactoringSuggestion {
kind: RefactoringKind::ExtractFunction,
description: "Extract shared helper".to_string(),
estimated_savings: 3,
},
suggested_name: Some("buildValue".to_string()),
}],
};
let rendered = plain(&build_clone_trace_human_lines(&trace, &root));
assert!(rendered.contains("FOUND clone at src/a.ts:10-12"));
assert!(rendered.contains("1 clone group(s) containing this location"));
assert!(rendered.contains("Clone group 1 dup:abc123"));
assert!(rendered.contains(">> src/a.ts:10-12"));
assert!(rendered.contains("-> src/b.ts:20-22"));
assert!(rendered.contains("Suggested refactor"));
assert!(rendered.contains("Extract function, saves ~3 lines"));
assert!(rendered.contains("Proposed name: buildValue"));
assert!(rendered.contains("Code fragment:"));
assert!(rendered.contains("10 const x = 1;"));
assert!(rendered.contains("Docs:"));
}
fn clone_instance(
file: &str,
start_line: usize,
end_line: usize,
fragment: &str,
) -> CloneInstance {
CloneInstance {
file: PathBuf::from(file),
start_line,
end_line,
start_col: 0,
end_col: 10,
fragment: fragment.to_string(),
}
}
}