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 let state = match o.ownership_state {
352 crate::health_types::OwnershipState::Active => "active",
353 crate::health_types::OwnershipState::Unowned => "unowned",
354 crate::health_types::OwnershipState::DeclaredInactive => "declared_inactive",
355 crate::health_types::OwnershipState::Drifting => "drifting",
356 };
357 parts.push(format!("ownership_state={state}"));
358 if o.drift {
359 parts.push("drift=true".to_string());
360 }
361 format!(",{}", parts.join(","))
362 })
363 .unwrap_or_default();
364 println!(
365 "hotspot:{}:score={:.1},commits={},churn={},density={:.2},fan_in={},trend={}{}",
366 relative,
367 entry.score,
368 entry.commits,
369 entry.lines_added + entry.lines_deleted,
370 entry.complexity_density,
371 entry.fan_in,
372 entry.trend,
373 ownership_suffix,
374 );
375 }
376 if let Some(ref trend) = report.health_trend {
377 println!(
378 "trend:overall:direction={}",
379 trend.overall_direction.label()
380 );
381 for m in &trend.metrics {
382 println!(
383 "trend:{}:previous={:.1},current={:.1},delta={:+.1},direction={}",
384 m.name,
385 m.previous,
386 m.current,
387 m.delta,
388 m.direction.label(),
389 );
390 }
391 }
392 for target in &report.targets {
393 let relative = normalize_uri(&relative_path(&target.path, root).display().to_string());
394 let category = target.category.compact_label();
395 let effort = target.effort.label();
396 let confidence = target.confidence.label();
397 println!(
398 "refactoring-target:{}:priority={:.1},efficiency={:.1},category={},effort={},confidence={}:{}",
399 relative,
400 target.priority,
401 target.efficiency,
402 category,
403 effort,
404 confidence,
405 target.recommendation,
406 );
407 }
408}
409
410fn build_runtime_coverage_compact_lines(
411 production: &crate::health_types::RuntimeCoverageReport,
412 root: &Path,
413) -> Vec<String> {
414 let mut lines = vec![format!(
415 "runtime-coverage-summary:functions_tracked={},functions_hit={},functions_unhit={},functions_untracked={},coverage_percent={:.1},trace_count={},period_days={},deployments_seen={}",
416 production.summary.functions_tracked,
417 production.summary.functions_hit,
418 production.summary.functions_unhit,
419 production.summary.functions_untracked,
420 production.summary.coverage_percent,
421 production.summary.trace_count,
422 production.summary.period_days,
423 production.summary.deployments_seen,
424 )];
425 for finding in &production.findings {
426 let relative = normalize_uri(&relative_path(&finding.path, root).display().to_string());
427 let invocations = finding
428 .invocations
429 .map_or_else(|| "null".to_owned(), |hits| hits.to_string());
430 lines.push(format!(
431 "runtime-coverage:{}:{}:{}:id={},verdict={},invocations={},confidence={}",
432 relative,
433 finding.line,
434 finding.function,
435 finding.id,
436 finding.verdict,
437 invocations,
438 finding.confidence,
439 ));
440 }
441 for entry in &production.hot_paths {
442 let relative = normalize_uri(&relative_path(&entry.path, root).display().to_string());
443 lines.push(format!(
444 "production-hot-path:{}:{}:{}:id={},invocations={},percentile={}",
445 relative, entry.line, entry.function, entry.id, entry.invocations, entry.percentile,
446 ));
447 }
448 lines
449}
450
451pub(super) fn print_duplication_compact(report: &DuplicationReport, root: &Path) {
452 for (i, group) in report.clone_groups.iter().enumerate() {
453 for instance in &group.instances {
454 let relative =
455 normalize_uri(&relative_path(&instance.file, root).display().to_string());
456 println!(
457 "clone-group-{}:{}:{}-{}:{}tokens",
458 i + 1,
459 relative,
460 instance.start_line,
461 instance.end_line,
462 group.token_count
463 );
464 }
465 }
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471 use crate::health_types::{
472 RuntimeCoverageConfidence, RuntimeCoverageDataSource, RuntimeCoverageEvidence,
473 RuntimeCoverageFinding, RuntimeCoverageHotPath, RuntimeCoverageReport,
474 RuntimeCoverageReportVerdict, RuntimeCoverageSchemaVersion, RuntimeCoverageSummary,
475 RuntimeCoverageVerdict,
476 };
477 use crate::report::test_helpers::sample_results;
478 use fallow_core::extract::MemberKind;
479 use fallow_core::results::*;
480 use std::path::PathBuf;
481
482 #[test]
483 fn compact_empty_results_no_lines() {
484 let root = PathBuf::from("/project");
485 let results = AnalysisResults::default();
486 let lines = build_compact_lines(&results, &root);
487 assert!(lines.is_empty());
488 }
489
490 #[test]
491 fn compact_unused_file_format() {
492 let root = PathBuf::from("/project");
493 let mut results = AnalysisResults::default();
494 results
495 .unused_files
496 .push(UnusedFileFinding::with_actions(UnusedFile {
497 path: root.join("src/dead.ts"),
498 }));
499
500 let lines = build_compact_lines(&results, &root);
501 assert_eq!(lines.len(), 1);
502 assert_eq!(lines[0], "unused-file:src/dead.ts");
503 }
504
505 #[test]
506 fn compact_unused_export_format() {
507 let root = PathBuf::from("/project");
508 let mut results = AnalysisResults::default();
509 results
510 .unused_exports
511 .push(UnusedExportFinding::with_actions(UnusedExport {
512 path: root.join("src/utils.ts"),
513 export_name: "helperFn".to_string(),
514 is_type_only: false,
515 line: 10,
516 col: 4,
517 span_start: 120,
518 is_re_export: false,
519 }));
520
521 let lines = build_compact_lines(&results, &root);
522 assert_eq!(lines[0], "unused-export:src/utils.ts:10:helperFn");
523 }
524
525 #[test]
526 fn compact_health_includes_runtime_coverage_lines() {
527 let root = PathBuf::from("/project");
528 let report = crate::health_types::HealthReport {
529 runtime_coverage: Some(RuntimeCoverageReport {
530 schema_version: RuntimeCoverageSchemaVersion::V1,
531 verdict: RuntimeCoverageReportVerdict::ColdCodeDetected,
532 signals: Vec::new(),
533 summary: RuntimeCoverageSummary {
534 data_source: RuntimeCoverageDataSource::Local,
535 last_received_at: None,
536 functions_tracked: 4,
537 functions_hit: 2,
538 functions_unhit: 1,
539 functions_untracked: 1,
540 coverage_percent: 50.0,
541 trace_count: 512,
542 period_days: 7,
543 deployments_seen: 2,
544 capture_quality: None,
545 },
546 findings: vec![RuntimeCoverageFinding {
547 id: "fallow:prod:deadbeef".to_owned(),
548 path: root.join("src/cold.ts"),
549 function: "coldPath".to_owned(),
550 line: 14,
551 verdict: RuntimeCoverageVerdict::ReviewRequired,
552 invocations: Some(0),
553 confidence: RuntimeCoverageConfidence::Medium,
554 evidence: RuntimeCoverageEvidence {
555 static_status: "used".to_owned(),
556 test_coverage: "not_covered".to_owned(),
557 v8_tracking: "tracked".to_owned(),
558 untracked_reason: None,
559 observation_days: 7,
560 deployments_observed: 2,
561 },
562 actions: vec![],
563 }],
564 hot_paths: vec![RuntimeCoverageHotPath {
565 id: "fallow:hot:cafebabe".to_owned(),
566 path: root.join("src/hot.ts"),
567 function: "hotPath".to_owned(),
568 line: 3,
569 end_line: 9,
570 invocations: 250,
571 percentile: 99,
572 actions: vec![],
573 }],
574 blast_radius: vec![],
575 importance: vec![],
576 watermark: None,
577 warnings: vec![],
578 }),
579 ..Default::default()
580 };
581
582 let lines = build_runtime_coverage_compact_lines(
583 report
584 .runtime_coverage
585 .as_ref()
586 .expect("runtime coverage should be set"),
587 &root,
588 );
589 assert_eq!(
590 lines[0],
591 "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"
592 );
593 assert_eq!(
594 lines[1],
595 "runtime-coverage:src/cold.ts:14:coldPath:id=fallow:prod:deadbeef,verdict=review_required,invocations=0,confidence=medium"
596 );
597 assert_eq!(
598 lines[2],
599 "production-hot-path:src/hot.ts:3:hotPath:id=fallow:hot:cafebabe,invocations=250,percentile=99"
600 );
601 }
602
603 #[test]
604 fn compact_unused_type_format() {
605 let root = PathBuf::from("/project");
606 let mut results = AnalysisResults::default();
607 results
608 .unused_types
609 .push(UnusedTypeFinding::with_actions(UnusedExport {
610 path: root.join("src/types.ts"),
611 export_name: "OldType".to_string(),
612 is_type_only: true,
613 line: 5,
614 col: 0,
615 span_start: 60,
616 is_re_export: false,
617 }));
618
619 let lines = build_compact_lines(&results, &root);
620 assert_eq!(lines[0], "unused-type:src/types.ts:5:OldType");
621 }
622
623 #[test]
624 fn compact_unused_dep_format() {
625 let root = PathBuf::from("/project");
626 let mut results = AnalysisResults::default();
627 results
628 .unused_dependencies
629 .push(UnusedDependencyFinding::with_actions(UnusedDependency {
630 package_name: "lodash".to_string(),
631 location: DependencyLocation::Dependencies,
632 path: root.join("package.json"),
633 line: 5,
634 used_in_workspaces: Vec::new(),
635 }));
636
637 let lines = build_compact_lines(&results, &root);
638 assert_eq!(lines[0], "unused-dep:lodash");
639 }
640
641 #[test]
642 fn compact_unused_devdep_format() {
643 let root = PathBuf::from("/project");
644 let mut results = AnalysisResults::default();
645 results
646 .unused_dev_dependencies
647 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
648 package_name: "jest".to_string(),
649 location: DependencyLocation::DevDependencies,
650 path: root.join("package.json"),
651 line: 5,
652 used_in_workspaces: Vec::new(),
653 }));
654
655 let lines = build_compact_lines(&results, &root);
656 assert_eq!(lines[0], "unused-devdep:jest");
657 }
658
659 #[test]
660 fn compact_unused_enum_member_format() {
661 let root = PathBuf::from("/project");
662 let mut results = AnalysisResults::default();
663 results
664 .unused_enum_members
665 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
666 path: root.join("src/enums.ts"),
667 parent_name: "Status".to_string(),
668 member_name: "Deprecated".to_string(),
669 kind: MemberKind::EnumMember,
670 line: 8,
671 col: 2,
672 }));
673
674 let lines = build_compact_lines(&results, &root);
675 assert_eq!(
676 lines[0],
677 "unused-enum-member:src/enums.ts:8:Status.Deprecated"
678 );
679 }
680
681 #[test]
682 fn compact_unused_class_member_format() {
683 let root = PathBuf::from("/project");
684 let mut results = AnalysisResults::default();
685 results
686 .unused_class_members
687 .push(UnusedClassMemberFinding::with_actions(UnusedMember {
688 path: root.join("src/service.ts"),
689 parent_name: "UserService".to_string(),
690 member_name: "legacyMethod".to_string(),
691 kind: MemberKind::ClassMethod,
692 line: 42,
693 col: 4,
694 }));
695
696 let lines = build_compact_lines(&results, &root);
697 assert_eq!(
698 lines[0],
699 "unused-class-member:src/service.ts:42:UserService.legacyMethod"
700 );
701 }
702
703 #[test]
704 fn compact_unresolved_import_format() {
705 let root = PathBuf::from("/project");
706 let mut results = AnalysisResults::default();
707 results
708 .unresolved_imports
709 .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
710 path: root.join("src/app.ts"),
711 specifier: "./missing-module".to_string(),
712 line: 3,
713 col: 0,
714 specifier_col: 0,
715 }));
716
717 let lines = build_compact_lines(&results, &root);
718 assert_eq!(lines[0], "unresolved-import:src/app.ts:3:./missing-module");
719 }
720
721 #[test]
722 fn compact_unlisted_dep_format() {
723 let root = PathBuf::from("/project");
724 let mut results = AnalysisResults::default();
725 results
726 .unlisted_dependencies
727 .push(UnlistedDependencyFinding::with_actions(
728 UnlistedDependency {
729 package_name: "chalk".to_string(),
730 imported_from: vec![],
731 },
732 ));
733
734 let lines = build_compact_lines(&results, &root);
735 assert_eq!(lines[0], "unlisted-dep:chalk");
736 }
737
738 #[test]
739 fn compact_duplicate_export_format() {
740 let root = PathBuf::from("/project");
741 let mut results = AnalysisResults::default();
742 results
743 .duplicate_exports
744 .push(DuplicateExportFinding::with_actions(DuplicateExport {
745 export_name: "Config".to_string(),
746 locations: vec![
747 DuplicateLocation {
748 path: root.join("src/a.ts"),
749 line: 15,
750 col: 0,
751 },
752 DuplicateLocation {
753 path: root.join("src/b.ts"),
754 line: 30,
755 col: 0,
756 },
757 ],
758 }));
759
760 let lines = build_compact_lines(&results, &root);
761 assert_eq!(lines[0], "duplicate-export:Config");
762 }
763
764 #[test]
765 fn compact_all_issue_types_produce_lines() {
766 let root = PathBuf::from("/project");
767 let results = sample_results(&root);
768 let lines = build_compact_lines(&results, &root);
769
770 assert_eq!(lines.len(), 16);
772
773 assert!(lines[0].starts_with("unused-file:"));
775 assert!(lines[1].starts_with("unused-export:"));
776 assert!(lines[2].starts_with("unused-type:"));
777 assert!(lines[3].starts_with("unused-dep:"));
778 assert!(lines[4].starts_with("unused-devdep:"));
779 assert!(lines[5].starts_with("unused-optionaldep:"));
780 assert!(lines[6].starts_with("unused-enum-member:"));
781 assert!(lines[7].starts_with("unused-class-member:"));
782 assert!(lines[8].starts_with("unresolved-import:"));
783 assert!(lines[9].starts_with("unlisted-dep:"));
784 assert!(lines[10].starts_with("duplicate-export:"));
785 assert!(lines[11].starts_with("type-only-dep:"));
786 assert!(lines[12].starts_with("test-only-dep:"));
787 assert!(lines[13].starts_with("circular-dependency:"));
788 assert!(lines[14].starts_with("boundary-violation:"));
789 }
790
791 #[test]
792 fn compact_strips_root_prefix_from_paths() {
793 let root = PathBuf::from("/project");
794 let mut results = AnalysisResults::default();
795 results
796 .unused_files
797 .push(UnusedFileFinding::with_actions(UnusedFile {
798 path: PathBuf::from("/project/src/deep/nested/file.ts"),
799 }));
800
801 let lines = build_compact_lines(&results, &root);
802 assert_eq!(lines[0], "unused-file:src/deep/nested/file.ts");
803 }
804
805 #[test]
808 fn compact_re_export_tagged_correctly() {
809 let root = PathBuf::from("/project");
810 let mut results = AnalysisResults::default();
811 results
812 .unused_exports
813 .push(UnusedExportFinding::with_actions(UnusedExport {
814 path: root.join("src/index.ts"),
815 export_name: "reExported".to_string(),
816 is_type_only: false,
817 line: 1,
818 col: 0,
819 span_start: 0,
820 is_re_export: true,
821 }));
822
823 let lines = build_compact_lines(&results, &root);
824 assert_eq!(lines[0], "unused-re-export:src/index.ts:1:reExported");
825 }
826
827 #[test]
828 fn compact_type_re_export_tagged_correctly() {
829 let root = PathBuf::from("/project");
830 let mut results = AnalysisResults::default();
831 results
832 .unused_types
833 .push(UnusedTypeFinding::with_actions(UnusedExport {
834 path: root.join("src/index.ts"),
835 export_name: "ReExportedType".to_string(),
836 is_type_only: true,
837 line: 3,
838 col: 0,
839 span_start: 0,
840 is_re_export: true,
841 }));
842
843 let lines = build_compact_lines(&results, &root);
844 assert_eq!(
845 lines[0],
846 "unused-re-export-type:src/index.ts:3:ReExportedType"
847 );
848 }
849
850 #[test]
853 fn compact_unused_optional_dep_format() {
854 let root = PathBuf::from("/project");
855 let mut results = AnalysisResults::default();
856 results
857 .unused_optional_dependencies
858 .push(UnusedOptionalDependencyFinding::with_actions(
859 UnusedDependency {
860 package_name: "fsevents".to_string(),
861 location: DependencyLocation::OptionalDependencies,
862 path: root.join("package.json"),
863 line: 12,
864 used_in_workspaces: Vec::new(),
865 },
866 ));
867
868 let lines = build_compact_lines(&results, &root);
869 assert_eq!(lines[0], "unused-optionaldep:fsevents");
870 }
871
872 #[test]
875 fn compact_circular_dependency_format() {
876 let root = PathBuf::from("/project");
877 let mut results = AnalysisResults::default();
878 results
879 .circular_dependencies
880 .push(CircularDependencyFinding::with_actions(
881 CircularDependency {
882 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
883 length: 2,
884 line: 3,
885 col: 0,
886 is_cross_package: false,
887 },
888 ));
889
890 let lines = build_compact_lines(&results, &root);
891 assert_eq!(lines.len(), 1);
892 assert!(lines[0].starts_with("circular-dependency:src/a.ts:3:"));
893 assert!(lines[0].contains("src/a.ts"));
894 assert!(lines[0].contains("src/b.ts"));
895 assert!(lines[0].contains("\u{2192}"));
897 }
898
899 #[test]
900 fn compact_circular_dependency_closes_cycle() {
901 let root = PathBuf::from("/project");
902 let mut results = AnalysisResults::default();
903 results
904 .circular_dependencies
905 .push(CircularDependencyFinding::with_actions(
906 CircularDependency {
907 files: vec![
908 root.join("src/a.ts"),
909 root.join("src/b.ts"),
910 root.join("src/c.ts"),
911 ],
912 length: 3,
913 line: 1,
914 col: 0,
915 is_cross_package: false,
916 },
917 ));
918
919 let lines = build_compact_lines(&results, &root);
920 let chain_part = lines[0].split(':').next_back().unwrap();
922 let parts: Vec<&str> = chain_part.split(" \u{2192} ").collect();
923 assert_eq!(parts.len(), 4);
924 assert_eq!(parts[0], parts[3]); }
926
927 #[test]
930 fn compact_type_only_dep_format() {
931 let root = PathBuf::from("/project");
932 let mut results = AnalysisResults::default();
933 results
934 .type_only_dependencies
935 .push(TypeOnlyDependencyFinding::with_actions(
936 TypeOnlyDependency {
937 package_name: "zod".to_string(),
938 path: root.join("package.json"),
939 line: 8,
940 },
941 ));
942
943 let lines = build_compact_lines(&results, &root);
944 assert_eq!(lines[0], "type-only-dep:zod");
945 }
946
947 #[test]
950 fn compact_multiple_unused_files() {
951 let root = PathBuf::from("/project");
952 let mut results = AnalysisResults::default();
953 results
954 .unused_files
955 .push(UnusedFileFinding::with_actions(UnusedFile {
956 path: root.join("src/a.ts"),
957 }));
958 results
959 .unused_files
960 .push(UnusedFileFinding::with_actions(UnusedFile {
961 path: root.join("src/b.ts"),
962 }));
963
964 let lines = build_compact_lines(&results, &root);
965 assert_eq!(lines.len(), 2);
966 assert_eq!(lines[0], "unused-file:src/a.ts");
967 assert_eq!(lines[1], "unused-file:src/b.ts");
968 }
969
970 #[test]
973 fn compact_ordering_optional_dep_between_devdep_and_enum() {
974 let root = PathBuf::from("/project");
975 let mut results = AnalysisResults::default();
976 results
977 .unused_dev_dependencies
978 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
979 package_name: "jest".to_string(),
980 location: DependencyLocation::DevDependencies,
981 path: root.join("package.json"),
982 line: 5,
983 used_in_workspaces: Vec::new(),
984 }));
985 results
986 .unused_optional_dependencies
987 .push(UnusedOptionalDependencyFinding::with_actions(
988 UnusedDependency {
989 package_name: "fsevents".to_string(),
990 location: DependencyLocation::OptionalDependencies,
991 path: root.join("package.json"),
992 line: 12,
993 used_in_workspaces: Vec::new(),
994 },
995 ));
996 results
997 .unused_enum_members
998 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
999 path: root.join("src/enums.ts"),
1000 parent_name: "Status".to_string(),
1001 member_name: "Deprecated".to_string(),
1002 kind: MemberKind::EnumMember,
1003 line: 8,
1004 col: 2,
1005 }));
1006
1007 let lines = build_compact_lines(&results, &root);
1008 assert_eq!(lines.len(), 3);
1009 assert!(lines[0].starts_with("unused-devdep:"));
1010 assert!(lines[1].starts_with("unused-optionaldep:"));
1011 assert!(lines[2].starts_with("unused-enum-member:"));
1012 }
1013
1014 #[test]
1017 fn compact_path_outside_root_preserved() {
1018 let root = PathBuf::from("/project");
1019 let mut results = AnalysisResults::default();
1020 results
1021 .unused_files
1022 .push(UnusedFileFinding::with_actions(UnusedFile {
1023 path: PathBuf::from("/other/place/file.ts"),
1024 }));
1025
1026 let lines = build_compact_lines(&results, &root);
1027 assert!(lines[0].contains("/other/place/file.ts"));
1028 }
1029}