Skip to main content

fallow_cli/report/
compact.rs

1use crate::report::sink::outln;
2use std::path::Path;
3
4use fallow_core::duplicates::{CloneFingerprintSet, DuplicationReport};
5use fallow_core::results::{AnalysisResults, UnusedExport, UnusedMember};
6
7use super::grouping::ResultGroup;
8use super::{normalize_uri, relative_path};
9
10pub(super) fn print_compact(results: &AnalysisResults, root: &Path) {
11    for line in build_compact_lines(results, root) {
12        outln!("{line}");
13    }
14}
15
16fn compact_path(path: &Path, root: &Path) -> String {
17    normalize_uri(&relative_path(path, root).display().to_string())
18}
19
20fn compact_circular_dependency_line(
21    cycle: &fallow_core::results::CircularDependencyFinding,
22    root: &Path,
23) -> String {
24    let chain: Vec<String> = cycle
25        .cycle
26        .files
27        .iter()
28        .map(|path| compact_path(path, root))
29        .collect();
30    let mut display_chain = chain.clone();
31    if let Some(first) = chain.first() {
32        display_chain.push(first.clone());
33    }
34    let first_file = chain.first().map_or_else(String::new, Clone::clone);
35    let cross_pkg_tag = if cycle.cycle.is_cross_package {
36        " (cross-package)"
37    } else {
38        ""
39    };
40    format!(
41        "circular-dependency:{}:{}:{}{}",
42        first_file,
43        cycle.cycle.line,
44        display_chain.join(" \u{2192} "),
45        cross_pkg_tag
46    )
47}
48
49fn compact_re_export_cycle_line(
50    cycle: &fallow_core::results::ReExportCycleFinding,
51    root: &Path,
52) -> String {
53    let chain: Vec<String> = cycle
54        .cycle
55        .files
56        .iter()
57        .map(|path| compact_path(path, root))
58        .collect();
59    let first_file = chain.first().map_or_else(String::new, Clone::clone);
60    let kind_tag = match cycle.cycle.kind {
61        fallow_core::results::ReExportCycleKind::SelfLoop => " (self-loop)",
62        fallow_core::results::ReExportCycleKind::MultiNode => "",
63    };
64    format!(
65        "re-export-cycle:{}:{}{}",
66        first_file,
67        chain.join(" <-> "),
68        kind_tag
69    )
70}
71
72fn compact_boundary_violation_line(
73    item: &fallow_core::results::BoundaryViolationFinding,
74    root: &Path,
75) -> String {
76    format!(
77        "boundary-violation:{}:{}:{} -> {} ({} -> {})",
78        compact_path(&item.violation.from_path, root),
79        item.violation.line,
80        compact_path(&item.violation.from_path, root),
81        compact_path(&item.violation.to_path, root),
82        item.violation.from_zone,
83        item.violation.to_zone,
84    )
85}
86
87fn compact_boundary_coverage_line(
88    item: &fallow_core::results::BoundaryCoverageViolationFinding,
89    root: &Path,
90) -> String {
91    format!(
92        "boundary-coverage:{}:{}:no matching boundary zone",
93        compact_path(&item.violation.path, root),
94        item.violation.line,
95    )
96}
97
98fn compact_boundary_call_line(
99    item: &fallow_core::results::BoundaryCallViolationFinding,
100    root: &Path,
101) -> String {
102    format!(
103        "boundary-call:{}:{}:{} forbidden in zone {} (pattern {})",
104        compact_path(&item.violation.path, root),
105        item.violation.line,
106        item.violation.callee,
107        item.violation.zone,
108        item.violation.pattern,
109    )
110}
111
112fn compact_stale_suppression_line(
113    item: &fallow_core::results::StaleSuppression,
114    root: &Path,
115) -> String {
116    format!(
117        "stale-suppression:{}:{}:{}",
118        compact_path(&item.path, root),
119        item.line,
120        item.display_message(),
121    )
122}
123
124fn compact_catalog_reference_line(
125    item: &fallow_core::results::UnresolvedCatalogReferenceFinding,
126    root: &Path,
127) -> String {
128    format!(
129        "unresolved-catalog-reference:{}:{}:{}:{}",
130        compact_path(&item.reference.path, root),
131        item.reference.line,
132        item.reference.catalog_name,
133        item.reference.entry_name,
134    )
135}
136
137fn compact_unused_override_line(
138    item: &fallow_core::results::UnusedDependencyOverrideFinding,
139    root: &Path,
140) -> String {
141    format!(
142        "unused-dependency-override:{}:{}:{}:{}",
143        compact_path(&item.entry.path, root),
144        item.entry.line,
145        item.entry.source.as_label(),
146        item.entry.raw_key,
147    )
148}
149
150fn compact_misconfigured_override_line(
151    item: &fallow_core::results::MisconfiguredDependencyOverrideFinding,
152    root: &Path,
153) -> String {
154    format!(
155        "misconfigured-dependency-override:{}:{}:{}:{}",
156        compact_path(&item.entry.path, root),
157        item.entry.line,
158        item.entry.source.as_label(),
159        item.entry.raw_key,
160    )
161}
162
163/// Build compact output lines for analysis results.
164/// Each issue is represented as a single `prefix:details` line.
165pub fn build_compact_lines(results: &AnalysisResults, root: &Path) -> Vec<String> {
166    CompactLineBuilder::new(results, root).build()
167}
168
169struct CompactLineBuilder<'a> {
170    lines: Vec<String>,
171    results: &'a AnalysisResults,
172    root: &'a Path,
173}
174
175impl<'a> CompactLineBuilder<'a> {
176    fn new(results: &'a AnalysisResults, root: &'a Path) -> Self {
177        Self {
178            lines: Vec::new(),
179            results,
180            root,
181        }
182    }
183
184    fn build(mut self) -> Vec<String> {
185        self.push_core_lines();
186        self.push_unused_dependency_lines();
187        self.push_member_lines();
188        self.push_secondary_dependency_lines();
189        self.push_graph_lines();
190        self.push_workspace_lines();
191        self.lines
192    }
193
194    fn rel(&self, path: &Path) -> String {
195        compact_path(path, self.root)
196    }
197
198    fn unused_export_line(&self, export: &UnusedExport) -> String {
199        let tag = if export.is_re_export {
200            "unused-re-export"
201        } else {
202            "unused-export"
203        };
204        format!(
205            "{}:{}:{}:{}",
206            tag,
207            self.rel(&export.path),
208            export.line,
209            export.export_name
210        )
211    }
212
213    fn unused_type_line(&self, export: &UnusedExport) -> String {
214        let tag = if export.is_re_export {
215            "unused-re-export-type"
216        } else {
217            "unused-type"
218        };
219        format!(
220            "{}:{}:{}:{}",
221            tag,
222            self.rel(&export.path),
223            export.line,
224            export.export_name
225        )
226    }
227
228    fn compact_member(&self, member: &UnusedMember, kind: &str) -> String {
229        format!(
230            "{}:{}:{}:{}.{}",
231            kind,
232            self.rel(&member.path),
233            member.line,
234            member.parent_name,
235            member.member_name
236        )
237    }
238
239    fn push_core_lines(&mut self) {
240        for file in &self.results.unused_files {
241            self.lines
242                .push(format!("unused-file:{}", self.rel(&file.file.path)));
243        }
244        for export in &self.results.unused_exports {
245            self.lines.push(self.unused_export_line(&export.export));
246        }
247        for export in &self.results.unused_types {
248            self.lines.push(self.unused_type_line(&export.export));
249        }
250        for leak in &self.results.private_type_leaks {
251            self.lines.push(format!(
252                "private-type-leak:{}:{}:{}->{}",
253                self.rel(&leak.leak.path),
254                leak.leak.line,
255                leak.leak.export_name,
256                leak.leak.type_name
257            ));
258        }
259    }
260
261    fn push_unused_dependency_lines(&mut self) {
262        for dep in &self.results.unused_dependencies {
263            self.lines
264                .push(format!("unused-dep:{}", dep.dep.package_name));
265        }
266        for dep in &self.results.unused_dev_dependencies {
267            self.lines
268                .push(format!("unused-devdep:{}", dep.dep.package_name));
269        }
270        for dep in &self.results.unused_optional_dependencies {
271            self.lines
272                .push(format!("unused-optionaldep:{}", dep.dep.package_name));
273        }
274    }
275
276    fn push_member_lines(&mut self) {
277        for member in &self.results.unused_enum_members {
278            self.lines
279                .push(self.compact_member(&member.member, "unused-enum-member"));
280        }
281        for member in &self.results.unused_class_members {
282            self.lines
283                .push(self.compact_member(&member.member, "unused-class-member"));
284        }
285        for member in &self.results.unused_store_members {
286            self.lines
287                .push(self.compact_member(&member.member, "unused-store-member"));
288        }
289        for import in &self.results.unresolved_imports {
290            self.lines.push(format!(
291                "unresolved-import:{}:{}:{}",
292                self.rel(&import.import.path),
293                import.import.line,
294                import.import.specifier
295            ));
296        }
297    }
298
299    fn push_secondary_dependency_lines(&mut self) {
300        for dep in &self.results.unlisted_dependencies {
301            self.lines
302                .push(format!("unlisted-dep:{}", dep.dep.package_name));
303        }
304        for dup in &self.results.duplicate_exports {
305            self.lines
306                .push(format!("duplicate-export:{}", dup.export.export_name));
307        }
308        for dep in &self.results.type_only_dependencies {
309            self.lines
310                .push(format!("type-only-dep:{}", dep.dep.package_name));
311        }
312        for dep in &self.results.test_only_dependencies {
313            self.lines
314                .push(format!("test-only-dep:{}", dep.dep.package_name));
315        }
316    }
317
318    fn push_graph_lines(&mut self) {
319        self.push_structure_lines();
320        self.push_framework_lines();
321        self.push_component_lines();
322        self.push_route_lines();
323        self.push_suppression_lines();
324    }
325
326    fn push_structure_lines(&mut self) {
327        for cycle in &self.results.circular_dependencies {
328            self.lines
329                .push(compact_circular_dependency_line(cycle, self.root));
330        }
331        for cycle in &self.results.re_export_cycles {
332            self.lines
333                .push(compact_re_export_cycle_line(cycle, self.root));
334        }
335        for violation in &self.results.boundary_violations {
336            self.lines
337                .push(compact_boundary_violation_line(violation, self.root));
338        }
339        for violation in &self.results.boundary_coverage_violations {
340            self.lines
341                .push(compact_boundary_coverage_line(violation, self.root));
342        }
343        for violation in &self.results.boundary_call_violations {
344            self.lines
345                .push(compact_boundary_call_line(violation, self.root));
346        }
347        for violation in &self.results.policy_violations {
348            self.lines.push(format!(
349                "policy-violation:{}:{}:{} banned by {}/{}",
350                self.rel(&violation.violation.path),
351                violation.violation.line,
352                violation.violation.matched,
353                violation.violation.pack,
354                violation.violation.rule_id,
355            ));
356        }
357    }
358
359    fn push_framework_lines(&mut self) {
360        for finding in &self.results.invalid_client_exports {
361            self.lines.push(format!(
362                "invalid-client-export:{}:{}:{} (from \"{}\")",
363                self.rel(&finding.export.path),
364                finding.export.line,
365                finding.export.export_name,
366                finding.export.directive,
367            ));
368        }
369        for finding in &self.results.mixed_client_server_barrels {
370            self.lines.push(format!(
371                "mixed-client-server-barrel:{}:{}:{} (server-only \"{}\")",
372                self.rel(&finding.barrel.path),
373                finding.barrel.line,
374                finding.barrel.client_origin,
375                finding.barrel.server_origin,
376            ));
377        }
378        for finding in &self.results.misplaced_directives {
379            self.lines.push(format!(
380                "misplaced-directive:{}:{}:{}",
381                self.rel(&finding.directive_site.path),
382                finding.directive_site.line,
383                finding.directive_site.directive,
384            ));
385        }
386        for finding in &self.results.unprovided_injects {
387            self.lines.push(format!(
388                "unprovided-inject:{}:{}:{}",
389                self.rel(&finding.inject.path),
390                finding.inject.line,
391                finding.inject.key_name,
392            ));
393        }
394    }
395
396    fn push_component_lines(&mut self) {
397        for finding in &self.results.unrendered_components {
398            self.lines.push(format!(
399                "unrendered-component:{}:{}:{}",
400                self.rel(&finding.component.path),
401                finding.component.line,
402                finding.component.component_name,
403            ));
404        }
405        for finding in &self.results.unused_component_props {
406            self.lines.push(format!(
407                "unused-component-prop:{}:{}:{}",
408                self.rel(&finding.prop.path),
409                finding.prop.line,
410                finding.prop.prop_name,
411            ));
412        }
413        for finding in &self.results.unused_component_emits {
414            self.lines.push(format!(
415                "unused-component-emit:{}:{}:{}",
416                self.rel(&finding.emit.path),
417                finding.emit.line,
418                finding.emit.emit_name,
419            ));
420        }
421        for finding in &self.results.unused_component_inputs {
422            self.lines.push(format!(
423                "unused-component-input:{}:{}:{}",
424                self.rel(&finding.input.path),
425                finding.input.line,
426                finding.input.input_name,
427            ));
428        }
429        for finding in &self.results.unused_component_outputs {
430            self.lines.push(format!(
431                "unused-component-output:{}:{}:{}",
432                self.rel(&finding.output.path),
433                finding.output.line,
434                finding.output.output_name,
435            ));
436        }
437        for finding in &self.results.unused_svelte_events {
438            self.lines.push(format!(
439                "unused-svelte-event:{}:{}:{}",
440                self.rel(&finding.event.path),
441                finding.event.line,
442                finding.event.event_name,
443            ));
444        }
445        for finding in &self.results.unused_server_actions {
446            self.lines.push(format!(
447                "unused-server-action:{}:{}:{}",
448                self.rel(&finding.action.path),
449                finding.action.line,
450                finding.action.action_name,
451            ));
452        }
453        for finding in &self.results.unused_load_data_keys {
454            self.lines.push(format!(
455                "unused-load-data-key:{}:{}:{}",
456                self.rel(&finding.key.path),
457                finding.key.line,
458                finding.key.key_name,
459            ));
460        }
461    }
462
463    fn push_route_lines(&mut self) {
464        for finding in &self.results.route_collisions {
465            self.lines.push(format!(
466                "route-collision:{}:{} (url {})",
467                self.rel(&finding.collision.path),
468                finding.collision.line,
469                finding.collision.url,
470            ));
471        }
472        for finding in &self.results.dynamic_segment_name_conflicts {
473            self.lines.push(format!(
474                "dynamic-segment-name-conflict:{}:{} ({} at {})",
475                self.rel(&finding.conflict.path),
476                finding.conflict.line,
477                finding.conflict.conflicting_segments.join(" vs "),
478                finding.conflict.position,
479            ));
480        }
481    }
482
483    fn push_suppression_lines(&mut self) {
484        for suppression in &self.results.stale_suppressions {
485            self.lines
486                .push(compact_stale_suppression_line(suppression, self.root));
487        }
488    }
489
490    fn push_workspace_lines(&mut self) {
491        for entry in &self.results.unused_catalog_entries {
492            self.lines.push(format!(
493                "unused-catalog-entry:{}:{}:{}:{}",
494                self.rel(&entry.entry.path),
495                entry.entry.line,
496                entry.entry.catalog_name,
497                entry.entry.entry_name,
498            ));
499        }
500        for group in &self.results.empty_catalog_groups {
501            self.lines.push(format!(
502                "empty-catalog-group:{}:{}:{}",
503                self.rel(&group.group.path),
504                group.group.line,
505                group.group.catalog_name,
506            ));
507        }
508        for finding in &self.results.unresolved_catalog_references {
509            self.lines
510                .push(compact_catalog_reference_line(finding, self.root));
511        }
512        for finding in &self.results.unused_dependency_overrides {
513            self.lines
514                .push(compact_unused_override_line(finding, self.root));
515        }
516        for finding in &self.results.misconfigured_dependency_overrides {
517            self.lines
518                .push(compact_misconfigured_override_line(finding, self.root));
519        }
520    }
521}
522
523/// Print grouped compact output: each line is prefixed with the group key.
524///
525/// Format: `group-key\tissue-tag:details`
526pub(super) fn print_grouped_compact(groups: &[ResultGroup], root: &Path) {
527    for group in groups {
528        for line in build_compact_lines(&group.results, root) {
529            outln!("{}\t{line}", group.key);
530        }
531    }
532}
533
534pub(super) fn print_health_compact(report: &crate::health_types::HealthReport, root: &Path) {
535    print_health_score_compact(report);
536    print_vital_signs_compact(report);
537    print_health_findings_compact(&report.findings, root);
538    print_threshold_overrides_compact(&report.threshold_overrides, root);
539    print_file_scores_compact(&report.file_scores, root);
540    print_coverage_gaps_compact(report, root);
541    print_runtime_sections_compact(report, root);
542    print_hotspots_compact(&report.hotspots, root);
543    print_health_trend_compact(report);
544    print_refactoring_targets_compact(&report.targets, root);
545}
546
547fn print_threshold_overrides_compact(
548    entries: &[crate::health_types::ThresholdOverrideState],
549    root: &Path,
550) {
551    for entry in entries {
552        let status = match entry.status {
553            crate::health_types::ThresholdOverrideStatus::Active => "active",
554            crate::health_types::ThresholdOverrideStatus::Stale => "stale",
555            crate::health_types::ThresholdOverrideStatus::NoMatch => "no_match",
556        };
557        let target = entry.path.as_ref().map_or_else(
558            || "no-match".to_string(),
559            |path| {
560                let display = health_compact_path(path, root);
561                entry
562                    .function
563                    .as_ref()
564                    .map_or_else(|| display.clone(), |name| format!("{display}:{name}"))
565            },
566        );
567        let metrics = entry.metrics.map_or(String::new(), |metrics| {
568            let crap = metrics
569                .crap
570                .map_or(String::new(), |value| format!(",crap={value:.1}"));
571            format!(
572                ",cyclomatic={},cognitive={}{}",
573                metrics.cyclomatic, metrics.cognitive, crap
574            )
575        });
576        outln!(
577            "threshold-override:{}:{}:{}{}",
578            entry.override_index,
579            status,
580            target,
581            metrics
582        );
583    }
584}
585
586fn print_health_score_compact(report: &crate::health_types::HealthReport) {
587    if let Some(ref hs) = report.health_score {
588        outln!("health-score:{:.1}:{}", hs.score, hs.grade);
589    }
590}
591
592fn print_vital_signs_compact(report: &crate::health_types::HealthReport) {
593    if let Some(ref vs) = report.vital_signs {
594        let mut parts = Vec::new();
595        if vs.total_loc > 0 {
596            parts.push(format!("total_loc={}", vs.total_loc));
597        }
598        parts.push(format!("avg_cyclomatic={:.1}", vs.avg_cyclomatic));
599        parts.push(format!("p90_cyclomatic={}", vs.p90_cyclomatic));
600        if let Some(v) = vs.dead_file_pct {
601            parts.push(format!("dead_file_pct={v:.1}"));
602        }
603        if let Some(v) = vs.dead_export_pct {
604            parts.push(format!("dead_export_pct={v:.1}"));
605        }
606        if let Some(v) = vs.maintainability_avg {
607            parts.push(format!("maintainability_avg={v:.1}"));
608        }
609        if let Some(v) = vs.hotspot_count {
610            parts.push(format!("hotspot_count={v}"));
611        }
612        if let Some(v) = vs.circular_dep_count {
613            parts.push(format!("circular_dep_count={v}"));
614        }
615        if let Some(v) = vs.unused_dep_count {
616            parts.push(format!("unused_dep_count={v}"));
617        }
618        outln!("vital-signs:{}", parts.join(","));
619    }
620}
621
622fn health_compact_path(path: &Path, root: &Path) -> String {
623    normalize_uri(&relative_path(path, root).display().to_string())
624}
625
626fn print_health_findings_compact(findings: &[crate::health_types::HealthFinding], root: &Path) {
627    for finding in findings {
628        let relative = health_compact_path(&finding.path, root);
629        let severity = match finding.severity {
630            crate::health_types::FindingSeverity::Critical => "critical",
631            crate::health_types::FindingSeverity::High => "high",
632            crate::health_types::FindingSeverity::Moderate => "moderate",
633        };
634        let crap_suffix = match finding.crap {
635            Some(crap) => {
636                let coverage = finding
637                    .coverage_pct
638                    .map(|pct| format!(",coverage_pct={pct:.1}"))
639                    .unwrap_or_default();
640                format!(",crap={crap:.1}{coverage}")
641            }
642            None => String::new(),
643        };
644        outln!(
645            "high-complexity:{}:{}:{}:cyclomatic={},cognitive={},severity={}{}",
646            relative,
647            finding.line,
648            finding.name,
649            finding.cyclomatic,
650            finding.cognitive,
651            severity,
652            crap_suffix,
653        );
654    }
655}
656
657fn print_file_scores_compact(scores: &[crate::health_types::FileHealthScore], root: &Path) {
658    for score in scores {
659        let relative = health_compact_path(&score.path, root);
660        outln!(
661            "file-score:{}:mi={:.1},fan_in={},fan_out={},dead={:.2},density={:.2},crap_max={:.1},crap_above={}",
662            relative,
663            score.maintainability_index,
664            score.fan_in,
665            score.fan_out,
666            score.dead_code_ratio,
667            score.complexity_density,
668            score.crap_max,
669            score.crap_above_threshold,
670        );
671    }
672}
673
674fn print_coverage_gaps_compact(report: &crate::health_types::HealthReport, root: &Path) {
675    if let Some(ref gaps) = report.coverage_gaps {
676        outln!(
677            "coverage-gap-summary:runtime_files={},covered_files={},file_coverage_pct={:.1},untested_files={},untested_exports={}",
678            gaps.summary.runtime_files,
679            gaps.summary.covered_files,
680            gaps.summary.file_coverage_pct,
681            gaps.summary.untested_files,
682            gaps.summary.untested_exports,
683        );
684        for item in &gaps.files {
685            let relative = health_compact_path(&item.file.path, root);
686            outln!(
687                "untested-file:{}:value_exports={}",
688                relative,
689                item.file.value_export_count,
690            );
691        }
692        for item in &gaps.exports {
693            let relative = health_compact_path(&item.export.path, root);
694            outln!(
695                "untested-export:{}:{}:{}",
696                relative,
697                item.export.line,
698                item.export.export_name,
699            );
700        }
701    }
702}
703
704fn print_runtime_sections_compact(report: &crate::health_types::HealthReport, root: &Path) {
705    if let Some(ref production) = report.runtime_coverage {
706        for line in build_runtime_coverage_compact_lines(production, root) {
707            outln!("{line}");
708        }
709    }
710    if let Some(ref intelligence) = report.coverage_intelligence {
711        for line in build_coverage_intelligence_compact_lines(intelligence, root) {
712            outln!("{line}");
713        }
714    }
715}
716
717fn compact_ownership_suffix(ownership: Option<&crate::health_types::OwnershipMetrics>) -> String {
718    ownership.map_or_else(String::new, |o| {
719        let mut parts = vec![
720            format!("bus={}", o.bus_factor),
721            format!("contributors={}", o.contributor_count),
722            format!("top={}", o.top_contributor.identifier),
723            format!("top_share={:.3}", o.top_contributor.share),
724        ];
725        if let Some(owner) = &o.declared_owner {
726            parts.push(format!("owner={owner}"));
727        }
728        if let Some(unowned) = o.unowned {
729            parts.push(format!("unowned={unowned}"));
730        }
731        let state = match o.ownership_state {
732            crate::health_types::OwnershipState::Active => "active",
733            crate::health_types::OwnershipState::Unowned => "unowned",
734            crate::health_types::OwnershipState::DeclaredInactive => "declared_inactive",
735            crate::health_types::OwnershipState::Drifting => "drifting",
736        };
737        parts.push(format!("ownership_state={state}"));
738        if o.drift {
739            parts.push("drift=true".to_string());
740        }
741        format!(",{}", parts.join(","))
742    })
743}
744
745fn print_hotspots_compact(hotspots: &[crate::health_types::HotspotFinding], root: &Path) {
746    for entry in hotspots {
747        let relative = health_compact_path(&entry.path, root);
748        let ownership_suffix = compact_ownership_suffix(entry.ownership.as_ref());
749        outln!(
750            "hotspot:{}:score={:.1},commits={},churn={},density={:.2},fan_in={},trend={}{}",
751            relative,
752            entry.score,
753            entry.commits,
754            entry.lines_added + entry.lines_deleted,
755            entry.complexity_density,
756            entry.fan_in,
757            entry.trend,
758            ownership_suffix,
759        );
760    }
761}
762
763fn print_health_trend_compact(report: &crate::health_types::HealthReport) {
764    if let Some(ref trend) = report.health_trend {
765        outln!(
766            "trend:overall:direction={}",
767            trend.overall_direction.label()
768        );
769        for m in &trend.metrics {
770            outln!(
771                "trend:{}:previous={:.1},current={:.1},delta={:+.1},direction={}",
772                m.name,
773                m.previous,
774                m.current,
775                m.delta,
776                m.direction.label(),
777            );
778        }
779    }
780}
781
782fn print_refactoring_targets_compact(
783    targets: &[crate::health_types::RefactoringTargetFinding],
784    root: &Path,
785) {
786    for target in targets {
787        let relative = health_compact_path(&target.path, root);
788        let category = target.category.compact_label();
789        let effort = target.effort.label();
790        let confidence = target.confidence.label();
791        outln!(
792            "refactoring-target:{}:priority={:.1},efficiency={:.1},category={},effort={},confidence={}:{}",
793            relative,
794            target.priority,
795            target.efficiency,
796            category,
797            effort,
798            confidence,
799            target.recommendation,
800        );
801    }
802}
803
804fn build_runtime_coverage_compact_lines(
805    production: &crate::health_types::RuntimeCoverageReport,
806    root: &Path,
807) -> Vec<String> {
808    let mut lines = vec![format!(
809        "runtime-coverage-summary:functions_tracked={},functions_hit={},functions_unhit={},functions_untracked={},coverage_percent={:.1},trace_count={},period_days={},deployments_seen={}",
810        production.summary.functions_tracked,
811        production.summary.functions_hit,
812        production.summary.functions_unhit,
813        production.summary.functions_untracked,
814        production.summary.coverage_percent,
815        production.summary.trace_count,
816        production.summary.period_days,
817        production.summary.deployments_seen,
818    )];
819    for finding in &production.findings {
820        let relative = normalize_uri(&relative_path(&finding.path, root).display().to_string());
821        let invocations = finding
822            .invocations
823            .map_or_else(|| "null".to_owned(), |hits| hits.to_string());
824        lines.push(format!(
825            "runtime-coverage:{}:{}:{}:id={},verdict={},invocations={},confidence={}",
826            relative,
827            finding.line,
828            finding.function,
829            finding.id,
830            finding.verdict,
831            invocations,
832            finding.confidence,
833        ));
834    }
835    for entry in &production.hot_paths {
836        let relative = normalize_uri(&relative_path(&entry.path, root).display().to_string());
837        lines.push(format!(
838            "production-hot-path:{}:{}:{}:id={},invocations={},percentile={}",
839            relative, entry.line, entry.function, entry.id, entry.invocations, entry.percentile,
840        ));
841    }
842    lines
843}
844
845fn build_coverage_intelligence_compact_lines(
846    intelligence: &crate::health_types::CoverageIntelligenceReport,
847    root: &Path,
848) -> Vec<String> {
849    let mut lines = vec![format!(
850        "coverage-intelligence-summary:verdict={},findings={},risky_changes={},high_confidence_deletes={},review_required={},refactor_carefully={},skipped_ambiguous_matches={}",
851        intelligence.verdict,
852        intelligence.summary.findings,
853        intelligence.summary.risky_changes,
854        intelligence.summary.high_confidence_deletes,
855        intelligence.summary.review_required,
856        intelligence.summary.refactor_carefully,
857        intelligence.summary.skipped_ambiguous_matches,
858    )];
859    for finding in &intelligence.findings {
860        let relative = normalize_uri(&relative_path(&finding.path, root).display().to_string());
861        let identity = finding.identity.as_deref().unwrap_or("-");
862        let signals = finding
863            .signals
864            .iter()
865            .map(ToString::to_string)
866            .collect::<Vec<_>>()
867            .join("+");
868        lines.push(format!(
869            "coverage-intelligence:{}:{}:{}:id={},verdict={},recommendation={},confidence={},signals={}",
870            relative,
871            finding.line,
872            identity,
873            finding.id,
874            finding.verdict,
875            finding.recommendation,
876            finding.confidence,
877            signals,
878        ));
879    }
880    lines
881}
882
883pub(super) fn print_duplication_compact(report: &DuplicationReport, root: &Path) {
884    let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
885    for (index, group) in report.clone_groups.iter().enumerate() {
886        let fingerprint = fingerprints.fingerprint_for_group(group);
887        for instance in &group.instances {
888            outln!(
889                "code-duplication:{}:{}-{}:fingerprint={},group={},tokens={},lines={},instances={}",
890                compact_path(&instance.file, root),
891                instance.start_line,
892                instance.end_line,
893                fingerprint,
894                index + 1,
895                group.token_count,
896                group.line_count,
897                group.instances.len(),
898            );
899        }
900    }
901}
902
903#[cfg(test)]
904mod tests {
905    use super::*;
906    use crate::health_types::{
907        RuntimeCoverageConfidence, RuntimeCoverageDataSource, RuntimeCoverageEvidence,
908        RuntimeCoverageFinding, RuntimeCoverageHotPath, RuntimeCoverageReport,
909        RuntimeCoverageReportVerdict, RuntimeCoverageSchemaVersion, RuntimeCoverageSummary,
910        RuntimeCoverageVerdict,
911    };
912    use crate::report::test_helpers::sample_results;
913    use fallow_core::extract::MemberKind;
914    use fallow_core::results::*;
915    use std::path::PathBuf;
916
917    #[test]
918    fn compact_empty_results_no_lines() {
919        let root = PathBuf::from("/project");
920        let results = AnalysisResults::default();
921        let lines = build_compact_lines(&results, &root);
922        assert!(lines.is_empty());
923    }
924
925    #[test]
926    fn compact_unused_file_format() {
927        let root = PathBuf::from("/project");
928        let mut results = AnalysisResults::default();
929        results
930            .unused_files
931            .push(UnusedFileFinding::with_actions(UnusedFile {
932                path: root.join("src/dead.ts"),
933            }));
934
935        let lines = build_compact_lines(&results, &root);
936        assert_eq!(lines.len(), 1);
937        assert_eq!(lines[0], "unused-file:src/dead.ts");
938    }
939
940    #[test]
941    fn compact_unused_export_format() {
942        let root = PathBuf::from("/project");
943        let mut results = AnalysisResults::default();
944        results
945            .unused_exports
946            .push(UnusedExportFinding::with_actions(UnusedExport {
947                path: root.join("src/utils.ts"),
948                export_name: "helperFn".to_string(),
949                is_type_only: false,
950                line: 10,
951                col: 4,
952                span_start: 120,
953                is_re_export: false,
954            }));
955
956        let lines = build_compact_lines(&results, &root);
957        assert_eq!(lines[0], "unused-export:src/utils.ts:10:helperFn");
958    }
959
960    #[test]
961    fn compact_health_includes_runtime_coverage_lines() {
962        let root = PathBuf::from("/project");
963        let report = crate::health_types::HealthReport {
964            runtime_coverage: Some(RuntimeCoverageReport {
965                schema_version: RuntimeCoverageSchemaVersion::V1,
966                verdict: RuntimeCoverageReportVerdict::ColdCodeDetected,
967                signals: Vec::new(),
968                summary: RuntimeCoverageSummary {
969                    data_source: RuntimeCoverageDataSource::Local,
970                    last_received_at: None,
971                    functions_tracked: 4,
972                    functions_hit: 2,
973                    functions_unhit: 1,
974                    functions_untracked: 1,
975                    coverage_percent: 50.0,
976                    trace_count: 512,
977                    period_days: 7,
978                    deployments_seen: 2,
979                    capture_quality: None,
980                },
981                findings: vec![RuntimeCoverageFinding {
982                    id: "fallow:prod:deadbeef".to_owned(),
983                    stable_id: None,
984                    path: root.join("src/cold.ts"),
985                    function: "coldPath".to_owned(),
986                    line: 14,
987                    verdict: RuntimeCoverageVerdict::ReviewRequired,
988                    invocations: Some(0),
989                    confidence: RuntimeCoverageConfidence::Medium,
990                    evidence: RuntimeCoverageEvidence {
991                        static_status: "used".to_owned(),
992                        test_coverage: "not_covered".to_owned(),
993                        v8_tracking: "tracked".to_owned(),
994                        untracked_reason: None,
995                        observation_days: 7,
996                        deployments_observed: 2,
997                    },
998                    actions: vec![],
999                    source_hash: None,
1000                }],
1001                hot_paths: vec![RuntimeCoverageHotPath {
1002                    id: "fallow:hot:cafebabe".to_owned(),
1003                    stable_id: None,
1004                    path: root.join("src/hot.ts"),
1005                    function: "hotPath".to_owned(),
1006                    line: 3,
1007                    end_line: 9,
1008                    invocations: 250,
1009                    percentile: 99,
1010                    actions: vec![],
1011                }],
1012                blast_radius: vec![],
1013                importance: vec![],
1014                watermark: None,
1015                warnings: vec![],
1016            }),
1017            ..Default::default()
1018        };
1019
1020        let lines = build_runtime_coverage_compact_lines(
1021            report
1022                .runtime_coverage
1023                .as_ref()
1024                .expect("runtime coverage should be set"),
1025            &root,
1026        );
1027        assert_eq!(
1028            lines[0],
1029            "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"
1030        );
1031        assert_eq!(
1032            lines[1],
1033            "runtime-coverage:src/cold.ts:14:coldPath:id=fallow:prod:deadbeef,verdict=review_required,invocations=0,confidence=medium"
1034        );
1035        assert_eq!(
1036            lines[2],
1037            "production-hot-path:src/hot.ts:3:hotPath:id=fallow:hot:cafebabe,invocations=250,percentile=99"
1038        );
1039    }
1040
1041    #[test]
1042    fn compact_health_includes_coverage_intelligence_lines() {
1043        use crate::health_types::{
1044            CoverageIntelligenceAction, CoverageIntelligenceConfidence,
1045            CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
1046            CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
1047            CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
1048            CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
1049        };
1050
1051        let root = PathBuf::from("/project");
1052        let report = CoverageIntelligenceReport {
1053            schema_version: CoverageIntelligenceSchemaVersion::V1,
1054            verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
1055            summary: CoverageIntelligenceSummary {
1056                findings: 1,
1057                high_confidence_deletes: 1,
1058                ..Default::default()
1059            },
1060            findings: vec![CoverageIntelligenceFinding {
1061                id: "fallow:coverage-intel:abc123".to_owned(),
1062                path: root.join("src/dead.ts"),
1063                identity: Some("deadPath".to_owned()),
1064                line: 9,
1065                verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
1066                signals: vec![
1067                    CoverageIntelligenceSignal::StaticUnused,
1068                    CoverageIntelligenceSignal::RuntimeCold,
1069                ],
1070                recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
1071                confidence: CoverageIntelligenceConfidence::High,
1072                related_ids: vec!["fallow:prod:deadbeef".to_owned()],
1073                evidence: CoverageIntelligenceEvidence {
1074                    match_confidence: CoverageIntelligenceMatchConfidence::Direct,
1075                    ..Default::default()
1076                },
1077                actions: vec![CoverageIntelligenceAction {
1078                    kind: "delete-after-confirming-owner".to_owned(),
1079                    description: "Confirm ownership".to_owned(),
1080                    auto_fixable: false,
1081                }],
1082            }],
1083        };
1084
1085        let lines = build_coverage_intelligence_compact_lines(&report, &root);
1086        assert_eq!(
1087            lines[0],
1088            "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"
1089        );
1090        assert_eq!(
1091            lines[1],
1092            "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"
1093        );
1094    }
1095
1096    #[test]
1097    fn compact_unused_type_format() {
1098        let root = PathBuf::from("/project");
1099        let mut results = AnalysisResults::default();
1100        results
1101            .unused_types
1102            .push(UnusedTypeFinding::with_actions(UnusedExport {
1103                path: root.join("src/types.ts"),
1104                export_name: "OldType".to_string(),
1105                is_type_only: true,
1106                line: 5,
1107                col: 0,
1108                span_start: 60,
1109                is_re_export: false,
1110            }));
1111
1112        let lines = build_compact_lines(&results, &root);
1113        assert_eq!(lines[0], "unused-type:src/types.ts:5:OldType");
1114    }
1115
1116    #[test]
1117    fn compact_unused_dep_format() {
1118        let root = PathBuf::from("/project");
1119        let mut results = AnalysisResults::default();
1120        results
1121            .unused_dependencies
1122            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1123                package_name: "lodash".to_string(),
1124                location: DependencyLocation::Dependencies,
1125                path: root.join("package.json"),
1126                line: 5,
1127                used_in_workspaces: Vec::new(),
1128            }));
1129
1130        let lines = build_compact_lines(&results, &root);
1131        assert_eq!(lines[0], "unused-dep:lodash");
1132    }
1133
1134    #[test]
1135    fn compact_unused_devdep_format() {
1136        let root = PathBuf::from("/project");
1137        let mut results = AnalysisResults::default();
1138        results
1139            .unused_dev_dependencies
1140            .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1141                package_name: "jest".to_string(),
1142                location: DependencyLocation::DevDependencies,
1143                path: root.join("package.json"),
1144                line: 5,
1145                used_in_workspaces: Vec::new(),
1146            }));
1147
1148        let lines = build_compact_lines(&results, &root);
1149        assert_eq!(lines[0], "unused-devdep:jest");
1150    }
1151
1152    #[test]
1153    fn compact_unused_enum_member_format() {
1154        let root = PathBuf::from("/project");
1155        let mut results = AnalysisResults::default();
1156        results
1157            .unused_enum_members
1158            .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
1159                path: root.join("src/enums.ts"),
1160                parent_name: "Status".to_string(),
1161                member_name: "Deprecated".to_string(),
1162                kind: MemberKind::EnumMember,
1163                line: 8,
1164                col: 2,
1165            }));
1166
1167        let lines = build_compact_lines(&results, &root);
1168        assert_eq!(
1169            lines[0],
1170            "unused-enum-member:src/enums.ts:8:Status.Deprecated"
1171        );
1172    }
1173
1174    #[test]
1175    fn compact_unused_class_member_format() {
1176        let root = PathBuf::from("/project");
1177        let mut results = AnalysisResults::default();
1178        results
1179            .unused_class_members
1180            .push(UnusedClassMemberFinding::with_actions(UnusedMember {
1181                path: root.join("src/service.ts"),
1182                parent_name: "UserService".to_string(),
1183                member_name: "legacyMethod".to_string(),
1184                kind: MemberKind::ClassMethod,
1185                line: 42,
1186                col: 4,
1187            }));
1188
1189        let lines = build_compact_lines(&results, &root);
1190        assert_eq!(
1191            lines[0],
1192            "unused-class-member:src/service.ts:42:UserService.legacyMethod"
1193        );
1194    }
1195
1196    #[test]
1197    fn compact_unresolved_import_format() {
1198        let root = PathBuf::from("/project");
1199        let mut results = AnalysisResults::default();
1200        results
1201            .unresolved_imports
1202            .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
1203                path: root.join("src/app.ts"),
1204                specifier: "./missing-module".to_string(),
1205                line: 3,
1206                col: 0,
1207                specifier_col: 0,
1208            }));
1209
1210        let lines = build_compact_lines(&results, &root);
1211        assert_eq!(lines[0], "unresolved-import:src/app.ts:3:./missing-module");
1212    }
1213
1214    #[test]
1215    fn compact_unlisted_dep_format() {
1216        let root = PathBuf::from("/project");
1217        let mut results = AnalysisResults::default();
1218        results
1219            .unlisted_dependencies
1220            .push(UnlistedDependencyFinding::with_actions(
1221                UnlistedDependency {
1222                    package_name: "chalk".to_string(),
1223                    imported_from: vec![],
1224                },
1225            ));
1226
1227        let lines = build_compact_lines(&results, &root);
1228        assert_eq!(lines[0], "unlisted-dep:chalk");
1229    }
1230
1231    #[test]
1232    fn compact_duplicate_export_format() {
1233        let root = PathBuf::from("/project");
1234        let mut results = AnalysisResults::default();
1235        results
1236            .duplicate_exports
1237            .push(DuplicateExportFinding::with_actions(DuplicateExport {
1238                export_name: "Config".to_string(),
1239                locations: vec![
1240                    DuplicateLocation {
1241                        path: root.join("src/a.ts"),
1242                        line: 15,
1243                        col: 0,
1244                    },
1245                    DuplicateLocation {
1246                        path: root.join("src/b.ts"),
1247                        line: 30,
1248                        col: 0,
1249                    },
1250                ],
1251            }));
1252
1253        let lines = build_compact_lines(&results, &root);
1254        assert_eq!(lines[0], "duplicate-export:Config");
1255    }
1256
1257    #[test]
1258    fn compact_all_issue_types_produce_lines() {
1259        let root = PathBuf::from("/project");
1260        let results = sample_results(&root);
1261        let lines = build_compact_lines(&results, &root);
1262
1263        assert_eq!(lines.len(), 26);
1264
1265        assert!(lines[0].starts_with("unused-file:"));
1266        assert!(lines[1].starts_with("unused-export:"));
1267        assert!(lines[2].starts_with("unused-type:"));
1268        assert!(lines[3].starts_with("unused-dep:"));
1269        assert!(lines[4].starts_with("unused-devdep:"));
1270        assert!(lines[5].starts_with("unused-optionaldep:"));
1271        assert!(lines[6].starts_with("unused-enum-member:"));
1272        assert!(lines[7].starts_with("unused-class-member:"));
1273        assert!(lines[8].starts_with("unused-store-member:"));
1274        assert!(lines[9].starts_with("unresolved-import:"));
1275        assert!(lines[10].starts_with("unlisted-dep:"));
1276        assert!(lines[11].starts_with("duplicate-export:"));
1277        assert!(lines[12].starts_with("type-only-dep:"));
1278        assert!(lines[13].starts_with("test-only-dep:"));
1279        assert!(lines[14].starts_with("circular-dependency:"));
1280        assert!(lines[15].starts_with("boundary-violation:"));
1281        assert!(lines.iter().any(|l| l.starts_with("unprovided-inject:")));
1282        assert!(lines.iter().any(|l| l.starts_with("unrendered-component:")));
1283        assert!(
1284            lines
1285                .iter()
1286                .any(|l| l.starts_with("unused-component-prop:"))
1287        );
1288        assert!(
1289            lines
1290                .iter()
1291                .any(|l| l.starts_with("unused-component-emit:"))
1292        );
1293        assert!(
1294            lines
1295                .iter()
1296                .any(|l| l.starts_with("unused-component-input:"))
1297        );
1298        assert!(
1299            lines
1300                .iter()
1301                .any(|l| l.starts_with("unused-component-output:"))
1302        );
1303        assert!(lines.iter().any(|l| l.starts_with("unused-svelte-event:")));
1304        assert!(lines.iter().any(|l| l.starts_with("unused-server-action:")));
1305        assert!(lines.iter().any(|l| l.starts_with("unused-load-data-key:")));
1306    }
1307
1308    #[test]
1309    fn compact_covers_api_and_boundary_variants() {
1310        let root = PathBuf::from("/project");
1311        let mut results = AnalysisResults::default();
1312        results
1313            .private_type_leaks
1314            .push(PrivateTypeLeakFinding::with_actions(PrivateTypeLeak {
1315                path: root.join("src/api.ts"),
1316                export_name: "createApi".to_owned(),
1317                type_name: "InternalShape".to_owned(),
1318                line: 12,
1319                col: 4,
1320                span_start: 100,
1321            }));
1322        results
1323            .circular_dependencies
1324            .push(CircularDependencyFinding::with_actions(
1325                CircularDependency {
1326                    files: vec![
1327                        root.join("packages/a/index.ts"),
1328                        root.join("packages/b/index.ts"),
1329                    ],
1330                    length: 2,
1331                    line: 3,
1332                    col: 0,
1333                    edges: Vec::new(),
1334                    is_cross_package: true,
1335                },
1336            ));
1337        results
1338            .boundary_coverage_violations
1339            .push(BoundaryCoverageViolationFinding::with_actions(
1340                BoundaryCoverageViolation {
1341                    path: root.join("src/unmatched.ts"),
1342                    line: 1,
1343                    col: 0,
1344                },
1345            ));
1346        results
1347            .boundary_call_violations
1348            .push(BoundaryCallViolationFinding::with_actions(
1349                BoundaryCallViolation {
1350                    path: root.join("src/ui/button.ts"),
1351                    line: 20,
1352                    col: 6,
1353                    zone: "ui".to_owned(),
1354                    callee: "child_process.exec".to_owned(),
1355                    pattern: "child_process.*".to_owned(),
1356                },
1357            ));
1358
1359        let lines = build_compact_lines(&results, &root);
1360
1361        assert_eq!(
1362            lines[0],
1363            "private-type-leak:src/api.ts:12:createApi->InternalShape"
1364        );
1365        assert!(lines[1].contains(" (cross-package)"));
1366        assert_eq!(
1367            lines[2],
1368            "boundary-coverage:src/unmatched.ts:1:no matching boundary zone"
1369        );
1370        assert_eq!(
1371            lines[3],
1372            "boundary-call:src/ui/button.ts:20:child_process.exec forbidden in zone ui (pattern child_process.*)"
1373        );
1374    }
1375
1376    #[test]
1377    fn compact_strips_root_prefix_from_paths() {
1378        let root = PathBuf::from("/project");
1379        let mut results = AnalysisResults::default();
1380        results
1381            .unused_files
1382            .push(UnusedFileFinding::with_actions(UnusedFile {
1383                path: PathBuf::from("/project/src/deep/nested/file.ts"),
1384            }));
1385
1386        let lines = build_compact_lines(&results, &root);
1387        assert_eq!(lines[0], "unused-file:src/deep/nested/file.ts");
1388    }
1389
1390    #[test]
1391    fn compact_re_export_tagged_correctly() {
1392        let root = PathBuf::from("/project");
1393        let mut results = AnalysisResults::default();
1394        results
1395            .unused_exports
1396            .push(UnusedExportFinding::with_actions(UnusedExport {
1397                path: root.join("src/index.ts"),
1398                export_name: "reExported".to_string(),
1399                is_type_only: false,
1400                line: 1,
1401                col: 0,
1402                span_start: 0,
1403                is_re_export: true,
1404            }));
1405
1406        let lines = build_compact_lines(&results, &root);
1407        assert_eq!(lines[0], "unused-re-export:src/index.ts:1:reExported");
1408    }
1409
1410    #[test]
1411    fn compact_type_re_export_tagged_correctly() {
1412        let root = PathBuf::from("/project");
1413        let mut results = AnalysisResults::default();
1414        results
1415            .unused_types
1416            .push(UnusedTypeFinding::with_actions(UnusedExport {
1417                path: root.join("src/index.ts"),
1418                export_name: "ReExportedType".to_string(),
1419                is_type_only: true,
1420                line: 3,
1421                col: 0,
1422                span_start: 0,
1423                is_re_export: true,
1424            }));
1425
1426        let lines = build_compact_lines(&results, &root);
1427        assert_eq!(
1428            lines[0],
1429            "unused-re-export-type:src/index.ts:3:ReExportedType"
1430        );
1431    }
1432
1433    #[test]
1434    fn compact_unused_optional_dep_format() {
1435        let root = PathBuf::from("/project");
1436        let mut results = AnalysisResults::default();
1437        results
1438            .unused_optional_dependencies
1439            .push(UnusedOptionalDependencyFinding::with_actions(
1440                UnusedDependency {
1441                    package_name: "fsevents".to_string(),
1442                    location: DependencyLocation::OptionalDependencies,
1443                    path: root.join("package.json"),
1444                    line: 12,
1445                    used_in_workspaces: Vec::new(),
1446                },
1447            ));
1448
1449        let lines = build_compact_lines(&results, &root);
1450        assert_eq!(lines[0], "unused-optionaldep:fsevents");
1451    }
1452
1453    #[test]
1454    fn compact_circular_dependency_format() {
1455        let root = PathBuf::from("/project");
1456        let mut results = AnalysisResults::default();
1457        results
1458            .circular_dependencies
1459            .push(CircularDependencyFinding::with_actions(
1460                CircularDependency {
1461                    files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1462                    length: 2,
1463                    line: 3,
1464                    col: 0,
1465                    edges: Vec::new(),
1466                    is_cross_package: false,
1467                },
1468            ));
1469
1470        let lines = build_compact_lines(&results, &root);
1471        assert_eq!(lines.len(), 1);
1472        assert!(lines[0].starts_with("circular-dependency:src/a.ts:3:"));
1473        assert!(lines[0].contains("src/a.ts"));
1474        assert!(lines[0].contains("src/b.ts"));
1475        assert!(lines[0].contains("\u{2192}"));
1476    }
1477
1478    #[test]
1479    fn compact_circular_dependency_closes_cycle() {
1480        let root = PathBuf::from("/project");
1481        let mut results = AnalysisResults::default();
1482        results
1483            .circular_dependencies
1484            .push(CircularDependencyFinding::with_actions(
1485                CircularDependency {
1486                    files: vec![
1487                        root.join("src/a.ts"),
1488                        root.join("src/b.ts"),
1489                        root.join("src/c.ts"),
1490                    ],
1491                    length: 3,
1492                    line: 1,
1493                    col: 0,
1494                    edges: Vec::new(),
1495                    is_cross_package: false,
1496                },
1497            ));
1498
1499        let lines = build_compact_lines(&results, &root);
1500        let chain_part = lines[0].split(':').next_back().unwrap();
1501        let parts: Vec<&str> = chain_part.split(" \u{2192} ").collect();
1502        assert_eq!(parts.len(), 4);
1503        assert_eq!(parts[0], parts[3]); // first == last (cycle closes)
1504    }
1505
1506    #[test]
1507    fn compact_type_only_dep_format() {
1508        let root = PathBuf::from("/project");
1509        let mut results = AnalysisResults::default();
1510        results
1511            .type_only_dependencies
1512            .push(TypeOnlyDependencyFinding::with_actions(
1513                TypeOnlyDependency {
1514                    package_name: "zod".to_string(),
1515                    path: root.join("package.json"),
1516                    line: 8,
1517                },
1518            ));
1519
1520        let lines = build_compact_lines(&results, &root);
1521        assert_eq!(lines[0], "type-only-dep:zod");
1522    }
1523
1524    #[test]
1525    fn compact_multiple_unused_files() {
1526        let root = PathBuf::from("/project");
1527        let mut results = AnalysisResults::default();
1528        results
1529            .unused_files
1530            .push(UnusedFileFinding::with_actions(UnusedFile {
1531                path: root.join("src/a.ts"),
1532            }));
1533        results
1534            .unused_files
1535            .push(UnusedFileFinding::with_actions(UnusedFile {
1536                path: root.join("src/b.ts"),
1537            }));
1538
1539        let lines = build_compact_lines(&results, &root);
1540        assert_eq!(lines.len(), 2);
1541        assert_eq!(lines[0], "unused-file:src/a.ts");
1542        assert_eq!(lines[1], "unused-file:src/b.ts");
1543    }
1544
1545    #[test]
1546    fn compact_ordering_optional_dep_between_devdep_and_enum() {
1547        let root = PathBuf::from("/project");
1548        let mut results = AnalysisResults::default();
1549        results
1550            .unused_dev_dependencies
1551            .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1552                package_name: "jest".to_string(),
1553                location: DependencyLocation::DevDependencies,
1554                path: root.join("package.json"),
1555                line: 5,
1556                used_in_workspaces: Vec::new(),
1557            }));
1558        results
1559            .unused_optional_dependencies
1560            .push(UnusedOptionalDependencyFinding::with_actions(
1561                UnusedDependency {
1562                    package_name: "fsevents".to_string(),
1563                    location: DependencyLocation::OptionalDependencies,
1564                    path: root.join("package.json"),
1565                    line: 12,
1566                    used_in_workspaces: Vec::new(),
1567                },
1568            ));
1569        results
1570            .unused_enum_members
1571            .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
1572                path: root.join("src/enums.ts"),
1573                parent_name: "Status".to_string(),
1574                member_name: "Deprecated".to_string(),
1575                kind: MemberKind::EnumMember,
1576                line: 8,
1577                col: 2,
1578            }));
1579
1580        let lines = build_compact_lines(&results, &root);
1581        assert_eq!(lines.len(), 3);
1582        assert!(lines[0].starts_with("unused-devdep:"));
1583        assert!(lines[1].starts_with("unused-optionaldep:"));
1584        assert!(lines[2].starts_with("unused-enum-member:"));
1585    }
1586
1587    #[test]
1588    fn compact_path_outside_root_preserved() {
1589        let root = PathBuf::from("/project");
1590        let mut results = AnalysisResults::default();
1591        results
1592            .unused_files
1593            .push(UnusedFileFinding::with_actions(UnusedFile {
1594                path: PathBuf::from("/other/place/file.ts"),
1595            }));
1596
1597        let lines = build_compact_lines(&results, &root);
1598        assert!(lines[0].contains("/other/place/file.ts"));
1599    }
1600}