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