1use std::path::Path;
2
3use fallow_core::duplicates::DuplicationReport;
4use fallow_core::results::{AnalysisResults, UnusedExport, UnusedMember};
5
6use super::grouping::ResultGroup;
7use super::{normalize_uri, relative_path};
8
9pub(super) fn print_compact(results: &AnalysisResults, root: &Path) {
10 for line in build_compact_lines(results, root) {
11 println!("{line}");
12 }
13}
14
15#[expect(
18 clippy::too_many_lines,
19 reason = "One uniform loop per issue type; the line count grows linearly with new issue types and the structure is clearer than extracting per-loop helpers."
20)]
21pub fn build_compact_lines(results: &AnalysisResults, root: &Path) -> Vec<String> {
22 let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
23
24 let compact_export = |export: &UnusedExport, kind: &str, re_kind: &str| -> String {
25 let tag = if export.is_re_export { re_kind } else { kind };
26 format!(
27 "{}:{}:{}:{}",
28 tag,
29 rel(&export.path),
30 export.line,
31 export.export_name
32 )
33 };
34
35 let compact_member = |member: &UnusedMember, kind: &str| -> String {
36 format!(
37 "{}:{}:{}:{}.{}",
38 kind,
39 rel(&member.path),
40 member.line,
41 member.parent_name,
42 member.member_name
43 )
44 };
45
46 let mut lines = Vec::new();
47
48 for file in &results.unused_files {
49 lines.push(format!("unused-file:{}", rel(&file.file.path)));
50 }
51 for export in &results.unused_exports {
52 lines.push(compact_export(
53 &export.export,
54 "unused-export",
55 "unused-re-export",
56 ));
57 }
58 for export in &results.unused_types {
59 lines.push(compact_export(
60 &export.export,
61 "unused-type",
62 "unused-re-export-type",
63 ));
64 }
65 for leak in &results.private_type_leaks {
66 lines.push(format!(
67 "private-type-leak:{}:{}:{}->{}",
68 rel(&leak.leak.path),
69 leak.leak.line,
70 leak.leak.export_name,
71 leak.leak.type_name
72 ));
73 }
74 for dep in &results.unused_dependencies {
75 lines.push(format!("unused-dep:{}", dep.dep.package_name));
76 }
77 for dep in &results.unused_dev_dependencies {
78 lines.push(format!("unused-devdep:{}", dep.dep.package_name));
79 }
80 for dep in &results.unused_optional_dependencies {
81 lines.push(format!("unused-optionaldep:{}", dep.dep.package_name));
82 }
83 for member in &results.unused_enum_members {
84 lines.push(compact_member(&member.member, "unused-enum-member"));
85 }
86 for member in &results.unused_class_members {
87 lines.push(compact_member(&member.member, "unused-class-member"));
88 }
89 for import in &results.unresolved_imports {
90 lines.push(format!(
91 "unresolved-import:{}:{}:{}",
92 rel(&import.import.path),
93 import.import.line,
94 import.import.specifier
95 ));
96 }
97 for dep in &results.unlisted_dependencies {
98 lines.push(format!("unlisted-dep:{}", dep.dep.package_name));
99 }
100 for dup in &results.duplicate_exports {
101 lines.push(format!("duplicate-export:{}", dup.export.export_name));
102 }
103 for dep in &results.type_only_dependencies {
104 lines.push(format!("type-only-dep:{}", dep.dep.package_name));
105 }
106 for dep in &results.test_only_dependencies {
107 lines.push(format!("test-only-dep:{}", dep.dep.package_name));
108 }
109 for cycle in &results.circular_dependencies {
110 let chain: Vec<String> = cycle.cycle.files.iter().map(|p| rel(p)).collect();
111 let mut display_chain = chain.clone();
112 if let Some(first) = chain.first() {
113 display_chain.push(first.clone());
114 }
115 let first_file = chain.first().map_or_else(String::new, Clone::clone);
116 let cross_pkg_tag = if cycle.cycle.is_cross_package {
117 " (cross-package)"
118 } else {
119 ""
120 };
121 lines.push(format!(
122 "circular-dependency:{}:{}:{}{}",
123 first_file,
124 cycle.cycle.line,
125 display_chain.join(" \u{2192} "),
126 cross_pkg_tag
127 ));
128 }
129 for cycle in &results.re_export_cycles {
130 let chain: Vec<String> = cycle.cycle.files.iter().map(|p| rel(p)).collect();
131 let first_file = chain.first().map_or_else(String::new, Clone::clone);
132 let kind_tag = match cycle.cycle.kind {
133 fallow_core::results::ReExportCycleKind::SelfLoop => " (self-loop)",
134 fallow_core::results::ReExportCycleKind::MultiNode => "",
135 };
136 lines.push(format!(
141 "re-export-cycle:{}:{}{}",
142 first_file,
143 chain.join(" <-> "),
144 kind_tag
145 ));
146 }
147 for v in &results.boundary_violations {
148 lines.push(format!(
149 "boundary-violation:{}:{}:{} -> {} ({} -> {})",
150 rel(&v.violation.from_path),
151 v.violation.line,
152 rel(&v.violation.from_path),
153 rel(&v.violation.to_path),
154 v.violation.from_zone,
155 v.violation.to_zone,
156 ));
157 }
158 for s in &results.stale_suppressions {
159 lines.push(format!(
160 "stale-suppression:{}:{}:{}",
161 rel(&s.path),
162 s.line,
163 s.display_message(),
164 ));
165 }
166 for entry in &results.unused_catalog_entries {
167 lines.push(format!(
168 "unused-catalog-entry:{}:{}:{}:{}",
169 rel(&entry.entry.path),
170 entry.entry.line,
171 entry.entry.catalog_name,
172 entry.entry.entry_name,
173 ));
174 }
175 for group in &results.empty_catalog_groups {
176 lines.push(format!(
177 "empty-catalog-group:{}:{}:{}",
178 rel(&group.group.path),
179 group.group.line,
180 group.group.catalog_name,
181 ));
182 }
183 for finding in &results.unresolved_catalog_references {
184 lines.push(format!(
185 "unresolved-catalog-reference:{}:{}:{}:{}",
186 rel(&finding.reference.path),
187 finding.reference.line,
188 finding.reference.catalog_name,
189 finding.reference.entry_name,
190 ));
191 }
192 for finding in &results.unused_dependency_overrides {
193 lines.push(format!(
194 "unused-dependency-override:{}:{}:{}:{}",
195 rel(&finding.entry.path),
196 finding.entry.line,
197 finding.entry.source.as_label(),
198 finding.entry.raw_key,
199 ));
200 }
201 for finding in &results.misconfigured_dependency_overrides {
202 lines.push(format!(
203 "misconfigured-dependency-override:{}:{}:{}:{}",
204 rel(&finding.entry.path),
205 finding.entry.line,
206 finding.entry.source.as_label(),
207 finding.entry.raw_key,
208 ));
209 }
210
211 lines
212}
213
214pub(super) fn print_grouped_compact(groups: &[ResultGroup], root: &Path) {
218 for group in groups {
219 for line in build_compact_lines(&group.results, root) {
220 println!("{}\t{line}", group.key);
221 }
222 }
223}
224
225#[expect(
226 clippy::too_many_lines,
227 reason = "health compact formatter stitches many optional sections into one stream"
228)]
229pub(super) fn print_health_compact(report: &crate::health_types::HealthReport, root: &Path) {
230 if let Some(ref hs) = report.health_score {
231 println!("health-score:{:.1}:{}", hs.score, hs.grade);
232 }
233 if let Some(ref vs) = report.vital_signs {
234 let mut parts = Vec::new();
235 if vs.total_loc > 0 {
236 parts.push(format!("total_loc={}", vs.total_loc));
237 }
238 parts.push(format!("avg_cyclomatic={:.1}", vs.avg_cyclomatic));
239 parts.push(format!("p90_cyclomatic={}", vs.p90_cyclomatic));
240 if let Some(v) = vs.dead_file_pct {
241 parts.push(format!("dead_file_pct={v:.1}"));
242 }
243 if let Some(v) = vs.dead_export_pct {
244 parts.push(format!("dead_export_pct={v:.1}"));
245 }
246 if let Some(v) = vs.maintainability_avg {
247 parts.push(format!("maintainability_avg={v:.1}"));
248 }
249 if let Some(v) = vs.hotspot_count {
250 parts.push(format!("hotspot_count={v}"));
251 }
252 if let Some(v) = vs.circular_dep_count {
253 parts.push(format!("circular_dep_count={v}"));
254 }
255 if let Some(v) = vs.unused_dep_count {
256 parts.push(format!("unused_dep_count={v}"));
257 }
258 println!("vital-signs:{}", parts.join(","));
259 }
260 for finding in &report.findings {
261 let relative = normalize_uri(&relative_path(&finding.path, root).display().to_string());
262 let severity = match finding.severity {
263 crate::health_types::FindingSeverity::Critical => "critical",
264 crate::health_types::FindingSeverity::High => "high",
265 crate::health_types::FindingSeverity::Moderate => "moderate",
266 };
267 let crap_suffix = match finding.crap {
268 Some(crap) => {
269 let coverage = finding
270 .coverage_pct
271 .map(|pct| format!(",coverage_pct={pct:.1}"))
272 .unwrap_or_default();
273 format!(",crap={crap:.1}{coverage}")
274 }
275 None => String::new(),
276 };
277 println!(
278 "high-complexity:{}:{}:{}:cyclomatic={},cognitive={},severity={}{}",
279 relative,
280 finding.line,
281 finding.name,
282 finding.cyclomatic,
283 finding.cognitive,
284 severity,
285 crap_suffix,
286 );
287 }
288 for score in &report.file_scores {
289 let relative = normalize_uri(&relative_path(&score.path, root).display().to_string());
290 println!(
291 "file-score:{}:mi={:.1},fan_in={},fan_out={},dead={:.2},density={:.2},crap_max={:.1},crap_above={}",
292 relative,
293 score.maintainability_index,
294 score.fan_in,
295 score.fan_out,
296 score.dead_code_ratio,
297 score.complexity_density,
298 score.crap_max,
299 score.crap_above_threshold,
300 );
301 }
302 if let Some(ref gaps) = report.coverage_gaps {
303 println!(
304 "coverage-gap-summary:runtime_files={},covered_files={},file_coverage_pct={:.1},untested_files={},untested_exports={}",
305 gaps.summary.runtime_files,
306 gaps.summary.covered_files,
307 gaps.summary.file_coverage_pct,
308 gaps.summary.untested_files,
309 gaps.summary.untested_exports,
310 );
311 for item in &gaps.files {
312 let relative =
313 normalize_uri(&relative_path(&item.file.path, root).display().to_string());
314 println!(
315 "untested-file:{}:value_exports={}",
316 relative, item.file.value_export_count,
317 );
318 }
319 for item in &gaps.exports {
320 let relative =
321 normalize_uri(&relative_path(&item.export.path, root).display().to_string());
322 println!(
323 "untested-export:{}:{}:{}",
324 relative, item.export.line, item.export.export_name,
325 );
326 }
327 }
328 if let Some(ref production) = report.runtime_coverage {
329 for line in build_runtime_coverage_compact_lines(production, root) {
330 println!("{line}");
331 }
332 }
333 for entry in &report.hotspots {
334 let relative = normalize_uri(&relative_path(&entry.path, root).display().to_string());
335 let ownership_suffix = entry
336 .ownership
337 .as_ref()
338 .map(|o| {
339 let mut parts = vec![
340 format!("bus={}", o.bus_factor),
341 format!("contributors={}", o.contributor_count),
342 format!("top={}", o.top_contributor.identifier),
343 format!("top_share={:.3}", o.top_contributor.share),
344 ];
345 if let Some(owner) = &o.declared_owner {
346 parts.push(format!("owner={owner}"));
347 }
348 if let Some(unowned) = o.unowned {
349 parts.push(format!("unowned={unowned}"));
350 }
351 if o.drift {
352 parts.push("drift=true".to_string());
353 }
354 format!(",{}", parts.join(","))
355 })
356 .unwrap_or_default();
357 println!(
358 "hotspot:{}:score={:.1},commits={},churn={},density={:.2},fan_in={},trend={}{}",
359 relative,
360 entry.score,
361 entry.commits,
362 entry.lines_added + entry.lines_deleted,
363 entry.complexity_density,
364 entry.fan_in,
365 entry.trend,
366 ownership_suffix,
367 );
368 }
369 if let Some(ref trend) = report.health_trend {
370 println!(
371 "trend:overall:direction={}",
372 trend.overall_direction.label()
373 );
374 for m in &trend.metrics {
375 println!(
376 "trend:{}:previous={:.1},current={:.1},delta={:+.1},direction={}",
377 m.name,
378 m.previous,
379 m.current,
380 m.delta,
381 m.direction.label(),
382 );
383 }
384 }
385 for target in &report.targets {
386 let relative = normalize_uri(&relative_path(&target.path, root).display().to_string());
387 let category = target.category.compact_label();
388 let effort = target.effort.label();
389 let confidence = target.confidence.label();
390 println!(
391 "refactoring-target:{}:priority={:.1},efficiency={:.1},category={},effort={},confidence={}:{}",
392 relative,
393 target.priority,
394 target.efficiency,
395 category,
396 effort,
397 confidence,
398 target.recommendation,
399 );
400 }
401}
402
403fn build_runtime_coverage_compact_lines(
404 production: &crate::health_types::RuntimeCoverageReport,
405 root: &Path,
406) -> Vec<String> {
407 let mut lines = vec![format!(
408 "runtime-coverage-summary:functions_tracked={},functions_hit={},functions_unhit={},functions_untracked={},coverage_percent={:.1},trace_count={},period_days={},deployments_seen={}",
409 production.summary.functions_tracked,
410 production.summary.functions_hit,
411 production.summary.functions_unhit,
412 production.summary.functions_untracked,
413 production.summary.coverage_percent,
414 production.summary.trace_count,
415 production.summary.period_days,
416 production.summary.deployments_seen,
417 )];
418 for finding in &production.findings {
419 let relative = normalize_uri(&relative_path(&finding.path, root).display().to_string());
420 let invocations = finding
421 .invocations
422 .map_or_else(|| "null".to_owned(), |hits| hits.to_string());
423 lines.push(format!(
424 "runtime-coverage:{}:{}:{}:id={},verdict={},invocations={},confidence={}",
425 relative,
426 finding.line,
427 finding.function,
428 finding.id,
429 finding.verdict,
430 invocations,
431 finding.confidence,
432 ));
433 }
434 for entry in &production.hot_paths {
435 let relative = normalize_uri(&relative_path(&entry.path, root).display().to_string());
436 lines.push(format!(
437 "production-hot-path:{}:{}:{}:id={},invocations={},percentile={}",
438 relative, entry.line, entry.function, entry.id, entry.invocations, entry.percentile,
439 ));
440 }
441 lines
442}
443
444pub(super) fn print_duplication_compact(report: &DuplicationReport, root: &Path) {
445 for (i, group) in report.clone_groups.iter().enumerate() {
446 for instance in &group.instances {
447 let relative =
448 normalize_uri(&relative_path(&instance.file, root).display().to_string());
449 println!(
450 "clone-group-{}:{}:{}-{}:{}tokens",
451 i + 1,
452 relative,
453 instance.start_line,
454 instance.end_line,
455 group.token_count
456 );
457 }
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use crate::health_types::{
465 RuntimeCoverageConfidence, RuntimeCoverageDataSource, RuntimeCoverageEvidence,
466 RuntimeCoverageFinding, RuntimeCoverageHotPath, RuntimeCoverageReport,
467 RuntimeCoverageReportVerdict, RuntimeCoverageSchemaVersion, RuntimeCoverageSummary,
468 RuntimeCoverageVerdict,
469 };
470 use crate::report::test_helpers::sample_results;
471 use fallow_core::extract::MemberKind;
472 use fallow_core::results::*;
473 use std::path::PathBuf;
474
475 #[test]
476 fn compact_empty_results_no_lines() {
477 let root = PathBuf::from("/project");
478 let results = AnalysisResults::default();
479 let lines = build_compact_lines(&results, &root);
480 assert!(lines.is_empty());
481 }
482
483 #[test]
484 fn compact_unused_file_format() {
485 let root = PathBuf::from("/project");
486 let mut results = AnalysisResults::default();
487 results
488 .unused_files
489 .push(UnusedFileFinding::with_actions(UnusedFile {
490 path: root.join("src/dead.ts"),
491 }));
492
493 let lines = build_compact_lines(&results, &root);
494 assert_eq!(lines.len(), 1);
495 assert_eq!(lines[0], "unused-file:src/dead.ts");
496 }
497
498 #[test]
499 fn compact_unused_export_format() {
500 let root = PathBuf::from("/project");
501 let mut results = AnalysisResults::default();
502 results
503 .unused_exports
504 .push(UnusedExportFinding::with_actions(UnusedExport {
505 path: root.join("src/utils.ts"),
506 export_name: "helperFn".to_string(),
507 is_type_only: false,
508 line: 10,
509 col: 4,
510 span_start: 120,
511 is_re_export: false,
512 }));
513
514 let lines = build_compact_lines(&results, &root);
515 assert_eq!(lines[0], "unused-export:src/utils.ts:10:helperFn");
516 }
517
518 #[test]
519 fn compact_health_includes_runtime_coverage_lines() {
520 let root = PathBuf::from("/project");
521 let report = crate::health_types::HealthReport {
522 runtime_coverage: Some(RuntimeCoverageReport {
523 schema_version: RuntimeCoverageSchemaVersion::V1,
524 verdict: RuntimeCoverageReportVerdict::ColdCodeDetected,
525 signals: Vec::new(),
526 summary: RuntimeCoverageSummary {
527 data_source: RuntimeCoverageDataSource::Local,
528 last_received_at: None,
529 functions_tracked: 4,
530 functions_hit: 2,
531 functions_unhit: 1,
532 functions_untracked: 1,
533 coverage_percent: 50.0,
534 trace_count: 512,
535 period_days: 7,
536 deployments_seen: 2,
537 capture_quality: None,
538 },
539 findings: vec![RuntimeCoverageFinding {
540 id: "fallow:prod:deadbeef".to_owned(),
541 path: root.join("src/cold.ts"),
542 function: "coldPath".to_owned(),
543 line: 14,
544 verdict: RuntimeCoverageVerdict::ReviewRequired,
545 invocations: Some(0),
546 confidence: RuntimeCoverageConfidence::Medium,
547 evidence: RuntimeCoverageEvidence {
548 static_status: "used".to_owned(),
549 test_coverage: "not_covered".to_owned(),
550 v8_tracking: "tracked".to_owned(),
551 untracked_reason: None,
552 observation_days: 7,
553 deployments_observed: 2,
554 },
555 actions: vec![],
556 }],
557 hot_paths: vec![RuntimeCoverageHotPath {
558 id: "fallow:hot:cafebabe".to_owned(),
559 path: root.join("src/hot.ts"),
560 function: "hotPath".to_owned(),
561 line: 3,
562 end_line: 9,
563 invocations: 250,
564 percentile: 99,
565 actions: vec![],
566 }],
567 blast_radius: vec![],
568 importance: vec![],
569 watermark: None,
570 warnings: vec![],
571 }),
572 ..Default::default()
573 };
574
575 let lines = build_runtime_coverage_compact_lines(
576 report
577 .runtime_coverage
578 .as_ref()
579 .expect("runtime coverage should be set"),
580 &root,
581 );
582 assert_eq!(
583 lines[0],
584 "runtime-coverage-summary:functions_tracked=4,functions_hit=2,functions_unhit=1,functions_untracked=1,coverage_percent=50.0,trace_count=512,period_days=7,deployments_seen=2"
585 );
586 assert_eq!(
587 lines[1],
588 "runtime-coverage:src/cold.ts:14:coldPath:id=fallow:prod:deadbeef,verdict=review_required,invocations=0,confidence=medium"
589 );
590 assert_eq!(
591 lines[2],
592 "production-hot-path:src/hot.ts:3:hotPath:id=fallow:hot:cafebabe,invocations=250,percentile=99"
593 );
594 }
595
596 #[test]
597 fn compact_unused_type_format() {
598 let root = PathBuf::from("/project");
599 let mut results = AnalysisResults::default();
600 results
601 .unused_types
602 .push(UnusedTypeFinding::with_actions(UnusedExport {
603 path: root.join("src/types.ts"),
604 export_name: "OldType".to_string(),
605 is_type_only: true,
606 line: 5,
607 col: 0,
608 span_start: 60,
609 is_re_export: false,
610 }));
611
612 let lines = build_compact_lines(&results, &root);
613 assert_eq!(lines[0], "unused-type:src/types.ts:5:OldType");
614 }
615
616 #[test]
617 fn compact_unused_dep_format() {
618 let root = PathBuf::from("/project");
619 let mut results = AnalysisResults::default();
620 results
621 .unused_dependencies
622 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
623 package_name: "lodash".to_string(),
624 location: DependencyLocation::Dependencies,
625 path: root.join("package.json"),
626 line: 5,
627 used_in_workspaces: Vec::new(),
628 }));
629
630 let lines = build_compact_lines(&results, &root);
631 assert_eq!(lines[0], "unused-dep:lodash");
632 }
633
634 #[test]
635 fn compact_unused_devdep_format() {
636 let root = PathBuf::from("/project");
637 let mut results = AnalysisResults::default();
638 results
639 .unused_dev_dependencies
640 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
641 package_name: "jest".to_string(),
642 location: DependencyLocation::DevDependencies,
643 path: root.join("package.json"),
644 line: 5,
645 used_in_workspaces: Vec::new(),
646 }));
647
648 let lines = build_compact_lines(&results, &root);
649 assert_eq!(lines[0], "unused-devdep:jest");
650 }
651
652 #[test]
653 fn compact_unused_enum_member_format() {
654 let root = PathBuf::from("/project");
655 let mut results = AnalysisResults::default();
656 results
657 .unused_enum_members
658 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
659 path: root.join("src/enums.ts"),
660 parent_name: "Status".to_string(),
661 member_name: "Deprecated".to_string(),
662 kind: MemberKind::EnumMember,
663 line: 8,
664 col: 2,
665 }));
666
667 let lines = build_compact_lines(&results, &root);
668 assert_eq!(
669 lines[0],
670 "unused-enum-member:src/enums.ts:8:Status.Deprecated"
671 );
672 }
673
674 #[test]
675 fn compact_unused_class_member_format() {
676 let root = PathBuf::from("/project");
677 let mut results = AnalysisResults::default();
678 results
679 .unused_class_members
680 .push(UnusedClassMemberFinding::with_actions(UnusedMember {
681 path: root.join("src/service.ts"),
682 parent_name: "UserService".to_string(),
683 member_name: "legacyMethod".to_string(),
684 kind: MemberKind::ClassMethod,
685 line: 42,
686 col: 4,
687 }));
688
689 let lines = build_compact_lines(&results, &root);
690 assert_eq!(
691 lines[0],
692 "unused-class-member:src/service.ts:42:UserService.legacyMethod"
693 );
694 }
695
696 #[test]
697 fn compact_unresolved_import_format() {
698 let root = PathBuf::from("/project");
699 let mut results = AnalysisResults::default();
700 results
701 .unresolved_imports
702 .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
703 path: root.join("src/app.ts"),
704 specifier: "./missing-module".to_string(),
705 line: 3,
706 col: 0,
707 specifier_col: 0,
708 }));
709
710 let lines = build_compact_lines(&results, &root);
711 assert_eq!(lines[0], "unresolved-import:src/app.ts:3:./missing-module");
712 }
713
714 #[test]
715 fn compact_unlisted_dep_format() {
716 let root = PathBuf::from("/project");
717 let mut results = AnalysisResults::default();
718 results
719 .unlisted_dependencies
720 .push(UnlistedDependencyFinding::with_actions(
721 UnlistedDependency {
722 package_name: "chalk".to_string(),
723 imported_from: vec![],
724 },
725 ));
726
727 let lines = build_compact_lines(&results, &root);
728 assert_eq!(lines[0], "unlisted-dep:chalk");
729 }
730
731 #[test]
732 fn compact_duplicate_export_format() {
733 let root = PathBuf::from("/project");
734 let mut results = AnalysisResults::default();
735 results
736 .duplicate_exports
737 .push(DuplicateExportFinding::with_actions(DuplicateExport {
738 export_name: "Config".to_string(),
739 locations: vec![
740 DuplicateLocation {
741 path: root.join("src/a.ts"),
742 line: 15,
743 col: 0,
744 },
745 DuplicateLocation {
746 path: root.join("src/b.ts"),
747 line: 30,
748 col: 0,
749 },
750 ],
751 }));
752
753 let lines = build_compact_lines(&results, &root);
754 assert_eq!(lines[0], "duplicate-export:Config");
755 }
756
757 #[test]
758 fn compact_all_issue_types_produce_lines() {
759 let root = PathBuf::from("/project");
760 let results = sample_results(&root);
761 let lines = build_compact_lines(&results, &root);
762
763 assert_eq!(lines.len(), 16);
765
766 assert!(lines[0].starts_with("unused-file:"));
768 assert!(lines[1].starts_with("unused-export:"));
769 assert!(lines[2].starts_with("unused-type:"));
770 assert!(lines[3].starts_with("unused-dep:"));
771 assert!(lines[4].starts_with("unused-devdep:"));
772 assert!(lines[5].starts_with("unused-optionaldep:"));
773 assert!(lines[6].starts_with("unused-enum-member:"));
774 assert!(lines[7].starts_with("unused-class-member:"));
775 assert!(lines[8].starts_with("unresolved-import:"));
776 assert!(lines[9].starts_with("unlisted-dep:"));
777 assert!(lines[10].starts_with("duplicate-export:"));
778 assert!(lines[11].starts_with("type-only-dep:"));
779 assert!(lines[12].starts_with("test-only-dep:"));
780 assert!(lines[13].starts_with("circular-dependency:"));
781 assert!(lines[14].starts_with("boundary-violation:"));
782 }
783
784 #[test]
785 fn compact_strips_root_prefix_from_paths() {
786 let root = PathBuf::from("/project");
787 let mut results = AnalysisResults::default();
788 results
789 .unused_files
790 .push(UnusedFileFinding::with_actions(UnusedFile {
791 path: PathBuf::from("/project/src/deep/nested/file.ts"),
792 }));
793
794 let lines = build_compact_lines(&results, &root);
795 assert_eq!(lines[0], "unused-file:src/deep/nested/file.ts");
796 }
797
798 #[test]
801 fn compact_re_export_tagged_correctly() {
802 let root = PathBuf::from("/project");
803 let mut results = AnalysisResults::default();
804 results
805 .unused_exports
806 .push(UnusedExportFinding::with_actions(UnusedExport {
807 path: root.join("src/index.ts"),
808 export_name: "reExported".to_string(),
809 is_type_only: false,
810 line: 1,
811 col: 0,
812 span_start: 0,
813 is_re_export: true,
814 }));
815
816 let lines = build_compact_lines(&results, &root);
817 assert_eq!(lines[0], "unused-re-export:src/index.ts:1:reExported");
818 }
819
820 #[test]
821 fn compact_type_re_export_tagged_correctly() {
822 let root = PathBuf::from("/project");
823 let mut results = AnalysisResults::default();
824 results
825 .unused_types
826 .push(UnusedTypeFinding::with_actions(UnusedExport {
827 path: root.join("src/index.ts"),
828 export_name: "ReExportedType".to_string(),
829 is_type_only: true,
830 line: 3,
831 col: 0,
832 span_start: 0,
833 is_re_export: true,
834 }));
835
836 let lines = build_compact_lines(&results, &root);
837 assert_eq!(
838 lines[0],
839 "unused-re-export-type:src/index.ts:3:ReExportedType"
840 );
841 }
842
843 #[test]
846 fn compact_unused_optional_dep_format() {
847 let root = PathBuf::from("/project");
848 let mut results = AnalysisResults::default();
849 results
850 .unused_optional_dependencies
851 .push(UnusedOptionalDependencyFinding::with_actions(
852 UnusedDependency {
853 package_name: "fsevents".to_string(),
854 location: DependencyLocation::OptionalDependencies,
855 path: root.join("package.json"),
856 line: 12,
857 used_in_workspaces: Vec::new(),
858 },
859 ));
860
861 let lines = build_compact_lines(&results, &root);
862 assert_eq!(lines[0], "unused-optionaldep:fsevents");
863 }
864
865 #[test]
868 fn compact_circular_dependency_format() {
869 let root = PathBuf::from("/project");
870 let mut results = AnalysisResults::default();
871 results
872 .circular_dependencies
873 .push(CircularDependencyFinding::with_actions(
874 CircularDependency {
875 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
876 length: 2,
877 line: 3,
878 col: 0,
879 is_cross_package: false,
880 },
881 ));
882
883 let lines = build_compact_lines(&results, &root);
884 assert_eq!(lines.len(), 1);
885 assert!(lines[0].starts_with("circular-dependency:src/a.ts:3:"));
886 assert!(lines[0].contains("src/a.ts"));
887 assert!(lines[0].contains("src/b.ts"));
888 assert!(lines[0].contains("\u{2192}"));
890 }
891
892 #[test]
893 fn compact_circular_dependency_closes_cycle() {
894 let root = PathBuf::from("/project");
895 let mut results = AnalysisResults::default();
896 results
897 .circular_dependencies
898 .push(CircularDependencyFinding::with_actions(
899 CircularDependency {
900 files: vec![
901 root.join("src/a.ts"),
902 root.join("src/b.ts"),
903 root.join("src/c.ts"),
904 ],
905 length: 3,
906 line: 1,
907 col: 0,
908 is_cross_package: false,
909 },
910 ));
911
912 let lines = build_compact_lines(&results, &root);
913 let chain_part = lines[0].split(':').next_back().unwrap();
915 let parts: Vec<&str> = chain_part.split(" \u{2192} ").collect();
916 assert_eq!(parts.len(), 4);
917 assert_eq!(parts[0], parts[3]); }
919
920 #[test]
923 fn compact_type_only_dep_format() {
924 let root = PathBuf::from("/project");
925 let mut results = AnalysisResults::default();
926 results
927 .type_only_dependencies
928 .push(TypeOnlyDependencyFinding::with_actions(
929 TypeOnlyDependency {
930 package_name: "zod".to_string(),
931 path: root.join("package.json"),
932 line: 8,
933 },
934 ));
935
936 let lines = build_compact_lines(&results, &root);
937 assert_eq!(lines[0], "type-only-dep:zod");
938 }
939
940 #[test]
943 fn compact_multiple_unused_files() {
944 let root = PathBuf::from("/project");
945 let mut results = AnalysisResults::default();
946 results
947 .unused_files
948 .push(UnusedFileFinding::with_actions(UnusedFile {
949 path: root.join("src/a.ts"),
950 }));
951 results
952 .unused_files
953 .push(UnusedFileFinding::with_actions(UnusedFile {
954 path: root.join("src/b.ts"),
955 }));
956
957 let lines = build_compact_lines(&results, &root);
958 assert_eq!(lines.len(), 2);
959 assert_eq!(lines[0], "unused-file:src/a.ts");
960 assert_eq!(lines[1], "unused-file:src/b.ts");
961 }
962
963 #[test]
966 fn compact_ordering_optional_dep_between_devdep_and_enum() {
967 let root = PathBuf::from("/project");
968 let mut results = AnalysisResults::default();
969 results
970 .unused_dev_dependencies
971 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
972 package_name: "jest".to_string(),
973 location: DependencyLocation::DevDependencies,
974 path: root.join("package.json"),
975 line: 5,
976 used_in_workspaces: Vec::new(),
977 }));
978 results
979 .unused_optional_dependencies
980 .push(UnusedOptionalDependencyFinding::with_actions(
981 UnusedDependency {
982 package_name: "fsevents".to_string(),
983 location: DependencyLocation::OptionalDependencies,
984 path: root.join("package.json"),
985 line: 12,
986 used_in_workspaces: Vec::new(),
987 },
988 ));
989 results
990 .unused_enum_members
991 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
992 path: root.join("src/enums.ts"),
993 parent_name: "Status".to_string(),
994 member_name: "Deprecated".to_string(),
995 kind: MemberKind::EnumMember,
996 line: 8,
997 col: 2,
998 }));
999
1000 let lines = build_compact_lines(&results, &root);
1001 assert_eq!(lines.len(), 3);
1002 assert!(lines[0].starts_with("unused-devdep:"));
1003 assert!(lines[1].starts_with("unused-optionaldep:"));
1004 assert!(lines[2].starts_with("unused-enum-member:"));
1005 }
1006
1007 #[test]
1010 fn compact_path_outside_root_preserved() {
1011 let root = PathBuf::from("/project");
1012 let mut results = AnalysisResults::default();
1013 results
1014 .unused_files
1015 .push(UnusedFileFinding::with_actions(UnusedFile {
1016 path: PathBuf::from("/other/place/file.ts"),
1017 }));
1018
1019 let lines = build_compact_lines(&results, &root);
1020 assert!(lines[0].contains("/other/place/file.ts"));
1021 }
1022}