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 if let Some(ref intelligence) = report.coverage_intelligence {
334 for line in build_coverage_intelligence_compact_lines(intelligence, root) {
335 println!("{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 println!(
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 println!(
383 "trend:overall:direction={}",
384 trend.overall_direction.label()
385 );
386 for m in &trend.metrics {
387 println!(
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 println!(
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 println!(
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);
873
874 assert!(lines[0].starts_with("unused-file:"));
876 assert!(lines[1].starts_with("unused-export:"));
877 assert!(lines[2].starts_with("unused-type:"));
878 assert!(lines[3].starts_with("unused-dep:"));
879 assert!(lines[4].starts_with("unused-devdep:"));
880 assert!(lines[5].starts_with("unused-optionaldep:"));
881 assert!(lines[6].starts_with("unused-enum-member:"));
882 assert!(lines[7].starts_with("unused-class-member:"));
883 assert!(lines[8].starts_with("unresolved-import:"));
884 assert!(lines[9].starts_with("unlisted-dep:"));
885 assert!(lines[10].starts_with("duplicate-export:"));
886 assert!(lines[11].starts_with("type-only-dep:"));
887 assert!(lines[12].starts_with("test-only-dep:"));
888 assert!(lines[13].starts_with("circular-dependency:"));
889 assert!(lines[14].starts_with("boundary-violation:"));
890 }
891
892 #[test]
893 fn compact_strips_root_prefix_from_paths() {
894 let root = PathBuf::from("/project");
895 let mut results = AnalysisResults::default();
896 results
897 .unused_files
898 .push(UnusedFileFinding::with_actions(UnusedFile {
899 path: PathBuf::from("/project/src/deep/nested/file.ts"),
900 }));
901
902 let lines = build_compact_lines(&results, &root);
903 assert_eq!(lines[0], "unused-file:src/deep/nested/file.ts");
904 }
905
906 #[test]
909 fn compact_re_export_tagged_correctly() {
910 let root = PathBuf::from("/project");
911 let mut results = AnalysisResults::default();
912 results
913 .unused_exports
914 .push(UnusedExportFinding::with_actions(UnusedExport {
915 path: root.join("src/index.ts"),
916 export_name: "reExported".to_string(),
917 is_type_only: false,
918 line: 1,
919 col: 0,
920 span_start: 0,
921 is_re_export: true,
922 }));
923
924 let lines = build_compact_lines(&results, &root);
925 assert_eq!(lines[0], "unused-re-export:src/index.ts:1:reExported");
926 }
927
928 #[test]
929 fn compact_type_re_export_tagged_correctly() {
930 let root = PathBuf::from("/project");
931 let mut results = AnalysisResults::default();
932 results
933 .unused_types
934 .push(UnusedTypeFinding::with_actions(UnusedExport {
935 path: root.join("src/index.ts"),
936 export_name: "ReExportedType".to_string(),
937 is_type_only: true,
938 line: 3,
939 col: 0,
940 span_start: 0,
941 is_re_export: true,
942 }));
943
944 let lines = build_compact_lines(&results, &root);
945 assert_eq!(
946 lines[0],
947 "unused-re-export-type:src/index.ts:3:ReExportedType"
948 );
949 }
950
951 #[test]
954 fn compact_unused_optional_dep_format() {
955 let root = PathBuf::from("/project");
956 let mut results = AnalysisResults::default();
957 results
958 .unused_optional_dependencies
959 .push(UnusedOptionalDependencyFinding::with_actions(
960 UnusedDependency {
961 package_name: "fsevents".to_string(),
962 location: DependencyLocation::OptionalDependencies,
963 path: root.join("package.json"),
964 line: 12,
965 used_in_workspaces: Vec::new(),
966 },
967 ));
968
969 let lines = build_compact_lines(&results, &root);
970 assert_eq!(lines[0], "unused-optionaldep:fsevents");
971 }
972
973 #[test]
976 fn compact_circular_dependency_format() {
977 let root = PathBuf::from("/project");
978 let mut results = AnalysisResults::default();
979 results
980 .circular_dependencies
981 .push(CircularDependencyFinding::with_actions(
982 CircularDependency {
983 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
984 length: 2,
985 line: 3,
986 col: 0,
987 is_cross_package: false,
988 },
989 ));
990
991 let lines = build_compact_lines(&results, &root);
992 assert_eq!(lines.len(), 1);
993 assert!(lines[0].starts_with("circular-dependency:src/a.ts:3:"));
994 assert!(lines[0].contains("src/a.ts"));
995 assert!(lines[0].contains("src/b.ts"));
996 assert!(lines[0].contains("\u{2192}"));
998 }
999
1000 #[test]
1001 fn compact_circular_dependency_closes_cycle() {
1002 let root = PathBuf::from("/project");
1003 let mut results = AnalysisResults::default();
1004 results
1005 .circular_dependencies
1006 .push(CircularDependencyFinding::with_actions(
1007 CircularDependency {
1008 files: vec![
1009 root.join("src/a.ts"),
1010 root.join("src/b.ts"),
1011 root.join("src/c.ts"),
1012 ],
1013 length: 3,
1014 line: 1,
1015 col: 0,
1016 is_cross_package: false,
1017 },
1018 ));
1019
1020 let lines = build_compact_lines(&results, &root);
1021 let chain_part = lines[0].split(':').next_back().unwrap();
1023 let parts: Vec<&str> = chain_part.split(" \u{2192} ").collect();
1024 assert_eq!(parts.len(), 4);
1025 assert_eq!(parts[0], parts[3]); }
1027
1028 #[test]
1031 fn compact_type_only_dep_format() {
1032 let root = PathBuf::from("/project");
1033 let mut results = AnalysisResults::default();
1034 results
1035 .type_only_dependencies
1036 .push(TypeOnlyDependencyFinding::with_actions(
1037 TypeOnlyDependency {
1038 package_name: "zod".to_string(),
1039 path: root.join("package.json"),
1040 line: 8,
1041 },
1042 ));
1043
1044 let lines = build_compact_lines(&results, &root);
1045 assert_eq!(lines[0], "type-only-dep:zod");
1046 }
1047
1048 #[test]
1051 fn compact_multiple_unused_files() {
1052 let root = PathBuf::from("/project");
1053 let mut results = AnalysisResults::default();
1054 results
1055 .unused_files
1056 .push(UnusedFileFinding::with_actions(UnusedFile {
1057 path: root.join("src/a.ts"),
1058 }));
1059 results
1060 .unused_files
1061 .push(UnusedFileFinding::with_actions(UnusedFile {
1062 path: root.join("src/b.ts"),
1063 }));
1064
1065 let lines = build_compact_lines(&results, &root);
1066 assert_eq!(lines.len(), 2);
1067 assert_eq!(lines[0], "unused-file:src/a.ts");
1068 assert_eq!(lines[1], "unused-file:src/b.ts");
1069 }
1070
1071 #[test]
1074 fn compact_ordering_optional_dep_between_devdep_and_enum() {
1075 let root = PathBuf::from("/project");
1076 let mut results = AnalysisResults::default();
1077 results
1078 .unused_dev_dependencies
1079 .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1080 package_name: "jest".to_string(),
1081 location: DependencyLocation::DevDependencies,
1082 path: root.join("package.json"),
1083 line: 5,
1084 used_in_workspaces: Vec::new(),
1085 }));
1086 results
1087 .unused_optional_dependencies
1088 .push(UnusedOptionalDependencyFinding::with_actions(
1089 UnusedDependency {
1090 package_name: "fsevents".to_string(),
1091 location: DependencyLocation::OptionalDependencies,
1092 path: root.join("package.json"),
1093 line: 12,
1094 used_in_workspaces: Vec::new(),
1095 },
1096 ));
1097 results
1098 .unused_enum_members
1099 .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
1100 path: root.join("src/enums.ts"),
1101 parent_name: "Status".to_string(),
1102 member_name: "Deprecated".to_string(),
1103 kind: MemberKind::EnumMember,
1104 line: 8,
1105 col: 2,
1106 }));
1107
1108 let lines = build_compact_lines(&results, &root);
1109 assert_eq!(lines.len(), 3);
1110 assert!(lines[0].starts_with("unused-devdep:"));
1111 assert!(lines[1].starts_with("unused-optionaldep:"));
1112 assert!(lines[2].starts_with("unused-enum-member:"));
1113 }
1114
1115 #[test]
1118 fn compact_path_outside_root_preserved() {
1119 let root = PathBuf::from("/project");
1120 let mut results = AnalysisResults::default();
1121 results
1122 .unused_files
1123 .push(UnusedFileFinding::with_actions(UnusedFile {
1124 path: PathBuf::from("/other/place/file.ts"),
1125 }));
1126
1127 let lines = build_compact_lines(&results, &root);
1128 assert!(lines[0].contains("/other/place/file.ts"));
1129 }
1130}