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