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