1use super::{
8 DeadCodeContext, DeadCodeReporter, group_by_category, location, reasons_clause, resolve_path,
9};
10use crate::reporter::{ReporterError, ReporterOptions};
11use crate::shared::{Style, extract_lines};
12use cpd_core::deadcode::{ConfidenceLevel, Finding};
13use std::collections::HashMap;
14use std::path::Path;
15
16pub struct ConsoleReporter {
17 style: Style,
18}
19
20impl ConsoleReporter {
21 pub fn new(options: &ReporterOptions) -> Self {
22 Self {
23 style: Style::new(options.no_colors),
24 }
25 }
26}
27
28impl DeadCodeReporter for ConsoleReporter {
29 fn name(&self) -> &str {
30 "console"
31 }
32
33 fn report(
34 &self,
35 findings: &[Finding],
36 ctx: &DeadCodeContext<'_>,
37 _output_dir: &Path,
38 ) -> Result<(), ReporterError> {
39 print_groups(findings, &self.style, |_| {});
40 print_trailer(findings, ctx, &self.style);
41 Ok(())
42 }
43}
44
45pub struct ConsoleFullReporter {
47 style: Style,
48}
49
50impl ConsoleFullReporter {
51 pub fn new(options: &ReporterOptions) -> Self {
52 Self {
53 style: Style::new(options.no_colors),
54 }
55 }
56}
57
58const UNPARSED_LISTED: usize = 10;
60
61const SNIPPET_LINES: u32 = 10;
65
66impl DeadCodeReporter for ConsoleFullReporter {
67 fn name(&self) -> &str {
68 "console-full"
69 }
70
71 fn report(
72 &self,
73 findings: &[Finding],
74 ctx: &DeadCodeContext<'_>,
75 _output_dir: &Path,
76 ) -> Result<(), ReporterError> {
77 let mut cache: HashMap<String, String> = HashMap::new();
78 let style = &self.style;
79 let roots = ctx.roots;
80 print_groups(findings, style, |finding| {
81 if finding.name.is_empty() {
84 return;
85 }
86 let Some(content) = read_source(&mut cache, &finding.path, roots) else {
87 return;
88 };
89 let last = finding.end.line.min(finding.start.line + SNIPPET_LINES - 1);
90 for (offset, line) in extract_lines(content, finding.start.line, last)
91 .lines()
92 .enumerate()
93 {
94 println!(
95 " {} {}",
96 style.dim(&format!("{:>5}", finding.start.line as usize + offset)),
97 line
98 );
99 }
100 if finding.end.line > last {
101 println!(" {}", style.dim(" ... "));
102 }
103 });
104 print_trailer(findings, ctx, style);
105 Ok(())
106 }
107}
108
109fn read_source<'a>(
112 cache: &'a mut HashMap<String, String>,
113 path: &str,
114 roots: &[std::path::PathBuf],
115) -> Option<&'a str> {
116 if !cache.contains_key(path) {
117 let content = resolve_path(path, roots).and_then(|p| std::fs::read_to_string(p).ok())?;
118 cache.insert(path.to_string(), content);
119 }
120 cache.get(path).map(String::as_str)
121}
122
123fn print_groups(findings: &[Finding], style: &Style, mut detail: impl FnMut(&Finding)) {
125 if findings.is_empty() {
126 println!("{}", style.green_prefix("No dead code found."));
127 return;
128 }
129 for (category, group) in group_by_category(findings) {
130 println!();
131 println!(
132 "{} {}",
133 style.bold(category.title()),
134 style.dim(&format!("({})", group.len()))
135 );
136 for finding in group {
137 print_finding(finding, style);
138 detail(finding);
139 }
140 }
141}
142
143fn print_finding(finding: &Finding, style: &Style) {
144 let subject = if finding.name.is_empty() {
145 style.bold_green(&finding.path)
146 } else {
147 format!(
148 "{} {}",
149 style.dim(&location(finding)),
150 style.bold_green(&display_name(finding)),
151 )
152 };
153 let kind = finding
154 .symbol_kind
155 .map(|k| format!("{} ", k.noun()))
156 .unwrap_or_default();
157 println!(
158 " - {}{} {} {}",
159 kind,
160 subject,
161 confidence_badge(finding, style),
162 style.dim(&format!("{} lines", finding.lines)),
163 );
164 let reasons = reasons_clause(finding);
165 if !reasons.is_empty() {
166 println!(" {}", style.dim(&format!("↳ {reasons}")));
167 }
168}
169
170fn display_name(finding: &Finding) -> String {
172 match (&finding.parent, &finding.exported_as) {
173 (Some(parent), _) => format!("{parent}.{}", finding.name),
174 (None, Some(exported)) => format!("{} (exported as {exported})", finding.name),
175 (None, None) => finding.name.clone(),
176 }
177}
178
179fn confidence_badge(finding: &Finding, style: &Style) -> String {
180 let text = format!("{} {}%", finding.level().as_str(), finding.confidence);
181 match finding.level() {
182 ConfidenceLevel::Certain | ConfidenceLevel::High => style.red(&text),
183 ConfidenceLevel::Medium => style.bold(&text),
184 ConfidenceLevel::Low => style.dim(&text),
185 }
186}
187
188fn print_trailer(findings: &[Finding], ctx: &DeadCodeContext<'_>, style: &Style) {
189 let stats = ctx.stats;
190 println!();
191 if findings.is_empty() {
192 println!(
193 "{}",
194 style.dim(&format!(
195 "Analyzed {} files, {} declarations, {} entry points.",
196 stats.files, stats.symbols, stats.entry_points
197 ))
198 );
199 } else {
200 println!(
201 "Found {} in {} files ({:.1}% of {} lines).",
202 style.bold(&format!("{} dead code findings", findings.len())),
203 stats.files,
204 stats.percentage,
205 stats.total_lines,
206 );
207 }
208 if stats.unparsed > 0 {
209 println!(
210 "{}",
211 style.dim(&format!(
212 "{} file(s) could not be parsed; their references are unknown:",
213 stats.unparsed
214 ))
215 );
216 for path in stats.unparsed_files.iter().take(UNPARSED_LISTED) {
219 println!("{}", style.dim(&format!(" - {path}")));
220 }
221 if stats.unparsed_files.len() > UNPARSED_LISTED {
222 println!(
223 "{}",
224 style.dim(&format!(
225 " ... and {} more (see the JSON report)",
226 stats.unparsed_files.len() - UNPARSED_LISTED
227 ))
228 );
229 }
230 }
231 println!("{}", style.dim(&format_duration(ctx.duration)));
232}
233
234fn format_duration(duration: std::time::Duration) -> String {
235 let millis = duration.as_millis();
236 if millis < 1000 {
237 format!("Done in {millis}ms")
238 } else {
239 format!("Done in {:.2}s", duration.as_secs_f64())
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::super::fixtures;
246 use super::*;
247 use cpd_core::deadcode::{Category, Stats};
248 use std::path::PathBuf;
249 use std::time::Duration;
250
251 fn options(no_colors: bool) -> ReporterOptions {
252 let mut options = ReporterOptions::new(PathBuf::from("/tmp"));
253 options.no_colors = no_colors;
254 options
255 }
256
257 #[test]
258 fn an_empty_report_does_not_panic() {
259 let stats = Stats::default();
260 let ctx = DeadCodeContext::new(&stats, Duration::ZERO);
261 for name in ["console", "console-full"] {
262 let reporter = super::super::create_dead_code_reporter(name, &options(true)).unwrap();
263 assert!(reporter.report(&[], &ctx, &PathBuf::from("/tmp")).is_ok());
264 }
265 }
266
267 #[test]
268 fn findings_are_rendered_without_panicking() {
269 let stats = fixtures::stats();
270 let ctx = DeadCodeContext::new(&stats, Duration::from_millis(12));
271 let reporter = ConsoleReporter::new(&options(true));
272 assert!(
273 reporter
274 .report(&fixtures::findings(), &ctx, &PathBuf::from("/tmp"))
275 .is_ok()
276 );
277 }
278
279 #[test]
280 fn console_full_survives_a_finding_whose_file_is_gone() {
281 let stats = fixtures::stats();
282 let ctx = DeadCodeContext::new(&stats, Duration::ZERO);
283 let reporter = ConsoleFullReporter::new(&options(true));
284 assert!(
285 reporter
286 .report(&fixtures::findings(), &ctx, &PathBuf::from("/tmp"))
287 .is_ok(),
288 "a deleted or unreadable file must not fail the report"
289 );
290 }
291
292 #[test]
293 fn a_member_is_shown_with_the_class_that_owns_it() {
294 let mut finding = fixtures::finding(Category::UnusedMember, "src/a.ts", "render", 70);
295 finding.parent = Some("Widget".into());
296 assert_eq!(display_name(&finding), "Widget.render");
297 }
298
299 #[test]
300 fn a_renamed_export_shows_both_names() {
301 let mut finding = fixtures::finding(Category::UnusedExport, "src/a.ts", "inner", 85);
302 finding.exported_as = Some("outer".into());
303 assert_eq!(display_name(&finding), "inner (exported as outer)");
304 }
305
306 #[test]
307 fn durations_switch_units_at_one_second() {
308 assert_eq!(format_duration(Duration::from_millis(12)), "Done in 12ms");
309 assert_eq!(format_duration(Duration::from_millis(999)), "Done in 999ms");
310 assert_eq!(
311 format_duration(Duration::from_millis(1500)),
312 "Done in 1.50s"
313 );
314 }
315
316 #[test]
317 fn no_colors_produces_no_escape_sequences() {
318 let style = Style::new(true);
319 let finding = fixtures::finding(Category::UnusedExport, "src/a.ts", "x", 85);
320 assert!(!confidence_badge(&finding, &style).contains('\x1b'));
321 }
322}