Skip to main content

fallow_cli/report/
compact.rs

1use crate::report::sink::outln;
2use std::path::Path;
3
4use fallow_core::duplicates::DuplicationReport;
5use fallow_core::results::{AnalysisResults, UnusedExport, UnusedMember};
6
7use super::grouping::ResultGroup;
8use super::{normalize_uri, relative_path};
9
10pub(super) fn print_compact(results: &AnalysisResults, root: &Path) {
11    for line in build_compact_lines(results, root) {
12        outln!("{line}");
13    }
14}
15
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_server_actions {
422            self.lines.push(format!(
423                "unused-server-action:{}:{}:{}",
424                self.rel(&finding.action.path),
425                finding.action.line,
426                finding.action.action_name,
427            ));
428        }
429        for finding in &self.results.unused_load_data_keys {
430            self.lines.push(format!(
431                "unused-load-data-key:{}:{}:{}",
432                self.rel(&finding.key.path),
433                finding.key.line,
434                finding.key.key_name,
435            ));
436        }
437    }
438
439    fn push_route_lines(&mut self) {
440        for finding in &self.results.route_collisions {
441            self.lines.push(format!(
442                "route-collision:{}:{} (url {})",
443                self.rel(&finding.collision.path),
444                finding.collision.line,
445                finding.collision.url,
446            ));
447        }
448        for finding in &self.results.dynamic_segment_name_conflicts {
449            self.lines.push(format!(
450                "dynamic-segment-name-conflict:{}:{} ({} at {})",
451                self.rel(&finding.conflict.path),
452                finding.conflict.line,
453                finding.conflict.conflicting_segments.join(" vs "),
454                finding.conflict.position,
455            ));
456        }
457    }
458
459    fn push_suppression_lines(&mut self) {
460        for suppression in &self.results.stale_suppressions {
461            self.lines
462                .push(compact_stale_suppression_line(suppression, self.root));
463        }
464    }
465
466    fn push_workspace_lines(&mut self) {
467        for entry in &self.results.unused_catalog_entries {
468            self.lines.push(format!(
469                "unused-catalog-entry:{}:{}:{}:{}",
470                self.rel(&entry.entry.path),
471                entry.entry.line,
472                entry.entry.catalog_name,
473                entry.entry.entry_name,
474            ));
475        }
476        for group in &self.results.empty_catalog_groups {
477            self.lines.push(format!(
478                "empty-catalog-group:{}:{}:{}",
479                self.rel(&group.group.path),
480                group.group.line,
481                group.group.catalog_name,
482            ));
483        }
484        for finding in &self.results.unresolved_catalog_references {
485            self.lines
486                .push(compact_catalog_reference_line(finding, self.root));
487        }
488        for finding in &self.results.unused_dependency_overrides {
489            self.lines
490                .push(compact_unused_override_line(finding, self.root));
491        }
492        for finding in &self.results.misconfigured_dependency_overrides {
493            self.lines
494                .push(compact_misconfigured_override_line(finding, self.root));
495        }
496    }
497}
498
499/// Print grouped compact output: each line is prefixed with the group key.
500///
501/// Format: `group-key\tissue-tag:details`
502pub(super) fn print_grouped_compact(groups: &[ResultGroup], root: &Path) {
503    for group in groups {
504        for line in build_compact_lines(&group.results, root) {
505            outln!("{}\t{line}", group.key);
506        }
507    }
508}
509
510pub(super) fn print_health_compact(report: &crate::health_types::HealthReport, root: &Path) {
511    print_health_score_compact(report);
512    print_vital_signs_compact(report);
513    print_health_findings_compact(&report.findings, root);
514    print_threshold_overrides_compact(&report.threshold_overrides, root);
515    print_file_scores_compact(&report.file_scores, root);
516    print_coverage_gaps_compact(report, root);
517    print_runtime_sections_compact(report, root);
518    print_hotspots_compact(&report.hotspots, root);
519    print_health_trend_compact(report);
520    print_refactoring_targets_compact(&report.targets, root);
521}
522
523fn print_threshold_overrides_compact(
524    entries: &[crate::health_types::ThresholdOverrideState],
525    root: &Path,
526) {
527    for entry in entries {
528        let status = match entry.status {
529            crate::health_types::ThresholdOverrideStatus::Active => "active",
530            crate::health_types::ThresholdOverrideStatus::Stale => "stale",
531            crate::health_types::ThresholdOverrideStatus::NoMatch => "no_match",
532        };
533        let target = entry.path.as_ref().map_or_else(
534            || "no-match".to_string(),
535            |path| {
536                let display = health_compact_path(path, root);
537                entry
538                    .function
539                    .as_ref()
540                    .map_or_else(|| display.clone(), |name| format!("{display}:{name}"))
541            },
542        );
543        let metrics = entry.metrics.map_or(String::new(), |metrics| {
544            let crap = metrics
545                .crap
546                .map_or(String::new(), |value| format!(",crap={value:.1}"));
547            format!(
548                ",cyclomatic={},cognitive={}{}",
549                metrics.cyclomatic, metrics.cognitive, crap
550            )
551        });
552        outln!(
553            "threshold-override:{}:{}:{}{}",
554            entry.override_index,
555            status,
556            target,
557            metrics
558        );
559    }
560}
561
562fn print_health_score_compact(report: &crate::health_types::HealthReport) {
563    if let Some(ref hs) = report.health_score {
564        outln!("health-score:{:.1}:{}", hs.score, hs.grade);
565    }
566}
567
568fn print_vital_signs_compact(report: &crate::health_types::HealthReport) {
569    if let Some(ref vs) = report.vital_signs {
570        let mut parts = Vec::new();
571        if vs.total_loc > 0 {
572            parts.push(format!("total_loc={}", vs.total_loc));
573        }
574        parts.push(format!("avg_cyclomatic={:.1}", vs.avg_cyclomatic));
575        parts.push(format!("p90_cyclomatic={}", vs.p90_cyclomatic));
576        if let Some(v) = vs.dead_file_pct {
577            parts.push(format!("dead_file_pct={v:.1}"));
578        }
579        if let Some(v) = vs.dead_export_pct {
580            parts.push(format!("dead_export_pct={v:.1}"));
581        }
582        if let Some(v) = vs.maintainability_avg {
583            parts.push(format!("maintainability_avg={v:.1}"));
584        }
585        if let Some(v) = vs.hotspot_count {
586            parts.push(format!("hotspot_count={v}"));
587        }
588        if let Some(v) = vs.circular_dep_count {
589            parts.push(format!("circular_dep_count={v}"));
590        }
591        if let Some(v) = vs.unused_dep_count {
592            parts.push(format!("unused_dep_count={v}"));
593        }
594        outln!("vital-signs:{}", parts.join(","));
595    }
596}
597
598fn health_compact_path(path: &Path, root: &Path) -> String {
599    normalize_uri(&relative_path(path, root).display().to_string())
600}
601
602fn print_health_findings_compact(findings: &[crate::health_types::HealthFinding], root: &Path) {
603    for finding in findings {
604        let relative = health_compact_path(&finding.path, root);
605        let severity = match finding.severity {
606            crate::health_types::FindingSeverity::Critical => "critical",
607            crate::health_types::FindingSeverity::High => "high",
608            crate::health_types::FindingSeverity::Moderate => "moderate",
609        };
610        let crap_suffix = match finding.crap {
611            Some(crap) => {
612                let coverage = finding
613                    .coverage_pct
614                    .map(|pct| format!(",coverage_pct={pct:.1}"))
615                    .unwrap_or_default();
616                format!(",crap={crap:.1}{coverage}")
617            }
618            None => String::new(),
619        };
620        outln!(
621            "high-complexity:{}:{}:{}:cyclomatic={},cognitive={},severity={}{}",
622            relative,
623            finding.line,
624            finding.name,
625            finding.cyclomatic,
626            finding.cognitive,
627            severity,
628            crap_suffix,
629        );
630    }
631}
632
633fn print_file_scores_compact(scores: &[crate::health_types::FileHealthScore], root: &Path) {
634    for score in scores {
635        let relative = health_compact_path(&score.path, root);
636        outln!(
637            "file-score:{}:mi={:.1},fan_in={},fan_out={},dead={:.2},density={:.2},crap_max={:.1},crap_above={}",
638            relative,
639            score.maintainability_index,
640            score.fan_in,
641            score.fan_out,
642            score.dead_code_ratio,
643            score.complexity_density,
644            score.crap_max,
645            score.crap_above_threshold,
646        );
647    }
648}
649
650fn print_coverage_gaps_compact(report: &crate::health_types::HealthReport, root: &Path) {
651    if let Some(ref gaps) = report.coverage_gaps {
652        outln!(
653            "coverage-gap-summary:runtime_files={},covered_files={},file_coverage_pct={:.1},untested_files={},untested_exports={}",
654            gaps.summary.runtime_files,
655            gaps.summary.covered_files,
656            gaps.summary.file_coverage_pct,
657            gaps.summary.untested_files,
658            gaps.summary.untested_exports,
659        );
660        for item in &gaps.files {
661            let relative = health_compact_path(&item.file.path, root);
662            outln!(
663                "untested-file:{}:value_exports={}",
664                relative,
665                item.file.value_export_count,
666            );
667        }
668        for item in &gaps.exports {
669            let relative = health_compact_path(&item.export.path, root);
670            outln!(
671                "untested-export:{}:{}:{}",
672                relative,
673                item.export.line,
674                item.export.export_name,
675            );
676        }
677    }
678}
679
680fn print_runtime_sections_compact(report: &crate::health_types::HealthReport, root: &Path) {
681    if let Some(ref production) = report.runtime_coverage {
682        for line in build_runtime_coverage_compact_lines(production, root) {
683            outln!("{line}");
684        }
685    }
686    if let Some(ref intelligence) = report.coverage_intelligence {
687        for line in build_coverage_intelligence_compact_lines(intelligence, root) {
688            outln!("{line}");
689        }
690    }
691}
692
693fn compact_ownership_suffix(ownership: Option<&crate::health_types::OwnershipMetrics>) -> String {
694    ownership.map_or_else(String::new, |o| {
695        let mut parts = vec![
696            format!("bus={}", o.bus_factor),
697            format!("contributors={}", o.contributor_count),
698            format!("top={}", o.top_contributor.identifier),
699            format!("top_share={:.3}", o.top_contributor.share),
700        ];
701        if let Some(owner) = &o.declared_owner {
702            parts.push(format!("owner={owner}"));
703        }
704        if let Some(unowned) = o.unowned {
705            parts.push(format!("unowned={unowned}"));
706        }
707        let state = match o.ownership_state {
708            crate::health_types::OwnershipState::Active => "active",
709            crate::health_types::OwnershipState::Unowned => "unowned",
710            crate::health_types::OwnershipState::DeclaredInactive => "declared_inactive",
711            crate::health_types::OwnershipState::Drifting => "drifting",
712        };
713        parts.push(format!("ownership_state={state}"));
714        if o.drift {
715            parts.push("drift=true".to_string());
716        }
717        format!(",{}", parts.join(","))
718    })
719}
720
721fn print_hotspots_compact(hotspots: &[crate::health_types::HotspotFinding], root: &Path) {
722    for entry in hotspots {
723        let relative = health_compact_path(&entry.path, root);
724        let ownership_suffix = compact_ownership_suffix(entry.ownership.as_ref());
725        outln!(
726            "hotspot:{}:score={:.1},commits={},churn={},density={:.2},fan_in={},trend={}{}",
727            relative,
728            entry.score,
729            entry.commits,
730            entry.lines_added + entry.lines_deleted,
731            entry.complexity_density,
732            entry.fan_in,
733            entry.trend,
734            ownership_suffix,
735        );
736    }
737}
738
739fn print_health_trend_compact(report: &crate::health_types::HealthReport) {
740    if let Some(ref trend) = report.health_trend {
741        outln!(
742            "trend:overall:direction={}",
743            trend.overall_direction.label()
744        );
745        for m in &trend.metrics {
746            outln!(
747                "trend:{}:previous={:.1},current={:.1},delta={:+.1},direction={}",
748                m.name,
749                m.previous,
750                m.current,
751                m.delta,
752                m.direction.label(),
753            );
754        }
755    }
756}
757
758fn print_refactoring_targets_compact(
759    targets: &[crate::health_types::RefactoringTargetFinding],
760    root: &Path,
761) {
762    for target in targets {
763        let relative = health_compact_path(&target.path, root);
764        let category = target.category.compact_label();
765        let effort = target.effort.label();
766        let confidence = target.confidence.label();
767        outln!(
768            "refactoring-target:{}:priority={:.1},efficiency={:.1},category={},effort={},confidence={}:{}",
769            relative,
770            target.priority,
771            target.efficiency,
772            category,
773            effort,
774            confidence,
775            target.recommendation,
776        );
777    }
778}
779
780fn build_runtime_coverage_compact_lines(
781    production: &crate::health_types::RuntimeCoverageReport,
782    root: &Path,
783) -> Vec<String> {
784    let mut lines = vec![format!(
785        "runtime-coverage-summary:functions_tracked={},functions_hit={},functions_unhit={},functions_untracked={},coverage_percent={:.1},trace_count={},period_days={},deployments_seen={}",
786        production.summary.functions_tracked,
787        production.summary.functions_hit,
788        production.summary.functions_unhit,
789        production.summary.functions_untracked,
790        production.summary.coverage_percent,
791        production.summary.trace_count,
792        production.summary.period_days,
793        production.summary.deployments_seen,
794    )];
795    for finding in &production.findings {
796        let relative = normalize_uri(&relative_path(&finding.path, root).display().to_string());
797        let invocations = finding
798            .invocations
799            .map_or_else(|| "null".to_owned(), |hits| hits.to_string());
800        lines.push(format!(
801            "runtime-coverage:{}:{}:{}:id={},verdict={},invocations={},confidence={}",
802            relative,
803            finding.line,
804            finding.function,
805            finding.id,
806            finding.verdict,
807            invocations,
808            finding.confidence,
809        ));
810    }
811    for entry in &production.hot_paths {
812        let relative = normalize_uri(&relative_path(&entry.path, root).display().to_string());
813        lines.push(format!(
814            "production-hot-path:{}:{}:{}:id={},invocations={},percentile={}",
815            relative, entry.line, entry.function, entry.id, entry.invocations, entry.percentile,
816        ));
817    }
818    lines
819}
820
821fn build_coverage_intelligence_compact_lines(
822    intelligence: &crate::health_types::CoverageIntelligenceReport,
823    root: &Path,
824) -> Vec<String> {
825    let mut lines = vec![format!(
826        "coverage-intelligence-summary:verdict={},findings={},risky_changes={},high_confidence_deletes={},review_required={},refactor_carefully={},skipped_ambiguous_matches={}",
827        intelligence.verdict,
828        intelligence.summary.findings,
829        intelligence.summary.risky_changes,
830        intelligence.summary.high_confidence_deletes,
831        intelligence.summary.review_required,
832        intelligence.summary.refactor_carefully,
833        intelligence.summary.skipped_ambiguous_matches,
834    )];
835    for finding in &intelligence.findings {
836        let relative = normalize_uri(&relative_path(&finding.path, root).display().to_string());
837        let identity = finding.identity.as_deref().unwrap_or("-");
838        let signals = finding
839            .signals
840            .iter()
841            .map(ToString::to_string)
842            .collect::<Vec<_>>()
843            .join("+");
844        lines.push(format!(
845            "coverage-intelligence:{}:{}:{}:id={},verdict={},recommendation={},confidence={},signals={}",
846            relative,
847            finding.line,
848            identity,
849            finding.id,
850            finding.verdict,
851            finding.recommendation,
852            finding.confidence,
853            signals,
854        ));
855    }
856    lines
857}
858
859pub(super) fn print_duplication_compact(report: &DuplicationReport, root: &Path) {
860    for (i, group) in report.clone_groups.iter().enumerate() {
861        for instance in &group.instances {
862            let relative =
863                normalize_uri(&relative_path(&instance.file, root).display().to_string());
864            outln!(
865                "clone-group-{}:{}:{}-{}:{}tokens",
866                i + 1,
867                relative,
868                instance.start_line,
869                instance.end_line,
870                group.token_count
871            );
872        }
873    }
874}
875
876#[cfg(test)]
877mod tests {
878    use super::*;
879    use crate::health_types::{
880        RuntimeCoverageConfidence, RuntimeCoverageDataSource, RuntimeCoverageEvidence,
881        RuntimeCoverageFinding, RuntimeCoverageHotPath, RuntimeCoverageReport,
882        RuntimeCoverageReportVerdict, RuntimeCoverageSchemaVersion, RuntimeCoverageSummary,
883        RuntimeCoverageVerdict,
884    };
885    use crate::report::test_helpers::sample_results;
886    use fallow_core::extract::MemberKind;
887    use fallow_core::results::*;
888    use std::path::PathBuf;
889
890    #[test]
891    fn compact_empty_results_no_lines() {
892        let root = PathBuf::from("/project");
893        let results = AnalysisResults::default();
894        let lines = build_compact_lines(&results, &root);
895        assert!(lines.is_empty());
896    }
897
898    #[test]
899    fn compact_unused_file_format() {
900        let root = PathBuf::from("/project");
901        let mut results = AnalysisResults::default();
902        results
903            .unused_files
904            .push(UnusedFileFinding::with_actions(UnusedFile {
905                path: root.join("src/dead.ts"),
906            }));
907
908        let lines = build_compact_lines(&results, &root);
909        assert_eq!(lines.len(), 1);
910        assert_eq!(lines[0], "unused-file:src/dead.ts");
911    }
912
913    #[test]
914    fn compact_unused_export_format() {
915        let root = PathBuf::from("/project");
916        let mut results = AnalysisResults::default();
917        results
918            .unused_exports
919            .push(UnusedExportFinding::with_actions(UnusedExport {
920                path: root.join("src/utils.ts"),
921                export_name: "helperFn".to_string(),
922                is_type_only: false,
923                line: 10,
924                col: 4,
925                span_start: 120,
926                is_re_export: false,
927            }));
928
929        let lines = build_compact_lines(&results, &root);
930        assert_eq!(lines[0], "unused-export:src/utils.ts:10:helperFn");
931    }
932
933    #[test]
934    fn compact_health_includes_runtime_coverage_lines() {
935        let root = PathBuf::from("/project");
936        let report = crate::health_types::HealthReport {
937            runtime_coverage: Some(RuntimeCoverageReport {
938                schema_version: RuntimeCoverageSchemaVersion::V1,
939                verdict: RuntimeCoverageReportVerdict::ColdCodeDetected,
940                signals: Vec::new(),
941                summary: RuntimeCoverageSummary {
942                    data_source: RuntimeCoverageDataSource::Local,
943                    last_received_at: None,
944                    functions_tracked: 4,
945                    functions_hit: 2,
946                    functions_unhit: 1,
947                    functions_untracked: 1,
948                    coverage_percent: 50.0,
949                    trace_count: 512,
950                    period_days: 7,
951                    deployments_seen: 2,
952                    capture_quality: None,
953                },
954                findings: vec![RuntimeCoverageFinding {
955                    id: "fallow:prod:deadbeef".to_owned(),
956                    stable_id: None,
957                    path: root.join("src/cold.ts"),
958                    function: "coldPath".to_owned(),
959                    line: 14,
960                    verdict: RuntimeCoverageVerdict::ReviewRequired,
961                    invocations: Some(0),
962                    confidence: RuntimeCoverageConfidence::Medium,
963                    evidence: RuntimeCoverageEvidence {
964                        static_status: "used".to_owned(),
965                        test_coverage: "not_covered".to_owned(),
966                        v8_tracking: "tracked".to_owned(),
967                        untracked_reason: None,
968                        observation_days: 7,
969                        deployments_observed: 2,
970                    },
971                    actions: vec![],
972                    source_hash: None,
973                }],
974                hot_paths: vec![RuntimeCoverageHotPath {
975                    id: "fallow:hot:cafebabe".to_owned(),
976                    stable_id: None,
977                    path: root.join("src/hot.ts"),
978                    function: "hotPath".to_owned(),
979                    line: 3,
980                    end_line: 9,
981                    invocations: 250,
982                    percentile: 99,
983                    actions: vec![],
984                }],
985                blast_radius: vec![],
986                importance: vec![],
987                watermark: None,
988                warnings: vec![],
989            }),
990            ..Default::default()
991        };
992
993        let lines = build_runtime_coverage_compact_lines(
994            report
995                .runtime_coverage
996                .as_ref()
997                .expect("runtime coverage should be set"),
998            &root,
999        );
1000        assert_eq!(
1001            lines[0],
1002            "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"
1003        );
1004        assert_eq!(
1005            lines[1],
1006            "runtime-coverage:src/cold.ts:14:coldPath:id=fallow:prod:deadbeef,verdict=review_required,invocations=0,confidence=medium"
1007        );
1008        assert_eq!(
1009            lines[2],
1010            "production-hot-path:src/hot.ts:3:hotPath:id=fallow:hot:cafebabe,invocations=250,percentile=99"
1011        );
1012    }
1013
1014    #[test]
1015    fn compact_health_includes_coverage_intelligence_lines() {
1016        use crate::health_types::{
1017            CoverageIntelligenceAction, CoverageIntelligenceConfidence,
1018            CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
1019            CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
1020            CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
1021            CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
1022        };
1023
1024        let root = PathBuf::from("/project");
1025        let report = CoverageIntelligenceReport {
1026            schema_version: CoverageIntelligenceSchemaVersion::V1,
1027            verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
1028            summary: CoverageIntelligenceSummary {
1029                findings: 1,
1030                high_confidence_deletes: 1,
1031                ..Default::default()
1032            },
1033            findings: vec![CoverageIntelligenceFinding {
1034                id: "fallow:coverage-intel:abc123".to_owned(),
1035                path: root.join("src/dead.ts"),
1036                identity: Some("deadPath".to_owned()),
1037                line: 9,
1038                verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
1039                signals: vec![
1040                    CoverageIntelligenceSignal::StaticUnused,
1041                    CoverageIntelligenceSignal::RuntimeCold,
1042                ],
1043                recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
1044                confidence: CoverageIntelligenceConfidence::High,
1045                related_ids: vec!["fallow:prod:deadbeef".to_owned()],
1046                evidence: CoverageIntelligenceEvidence {
1047                    match_confidence: CoverageIntelligenceMatchConfidence::Direct,
1048                    ..Default::default()
1049                },
1050                actions: vec![CoverageIntelligenceAction {
1051                    kind: "delete-after-confirming-owner".to_owned(),
1052                    description: "Confirm ownership".to_owned(),
1053                    auto_fixable: false,
1054                }],
1055            }],
1056        };
1057
1058        let lines = build_coverage_intelligence_compact_lines(&report, &root);
1059        assert_eq!(
1060            lines[0],
1061            "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"
1062        );
1063        assert_eq!(
1064            lines[1],
1065            "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"
1066        );
1067    }
1068
1069    #[test]
1070    fn compact_unused_type_format() {
1071        let root = PathBuf::from("/project");
1072        let mut results = AnalysisResults::default();
1073        results
1074            .unused_types
1075            .push(UnusedTypeFinding::with_actions(UnusedExport {
1076                path: root.join("src/types.ts"),
1077                export_name: "OldType".to_string(),
1078                is_type_only: true,
1079                line: 5,
1080                col: 0,
1081                span_start: 60,
1082                is_re_export: false,
1083            }));
1084
1085        let lines = build_compact_lines(&results, &root);
1086        assert_eq!(lines[0], "unused-type:src/types.ts:5:OldType");
1087    }
1088
1089    #[test]
1090    fn compact_unused_dep_format() {
1091        let root = PathBuf::from("/project");
1092        let mut results = AnalysisResults::default();
1093        results
1094            .unused_dependencies
1095            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
1096                package_name: "lodash".to_string(),
1097                location: DependencyLocation::Dependencies,
1098                path: root.join("package.json"),
1099                line: 5,
1100                used_in_workspaces: Vec::new(),
1101            }));
1102
1103        let lines = build_compact_lines(&results, &root);
1104        assert_eq!(lines[0], "unused-dep:lodash");
1105    }
1106
1107    #[test]
1108    fn compact_unused_devdep_format() {
1109        let root = PathBuf::from("/project");
1110        let mut results = AnalysisResults::default();
1111        results
1112            .unused_dev_dependencies
1113            .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1114                package_name: "jest".to_string(),
1115                location: DependencyLocation::DevDependencies,
1116                path: root.join("package.json"),
1117                line: 5,
1118                used_in_workspaces: Vec::new(),
1119            }));
1120
1121        let lines = build_compact_lines(&results, &root);
1122        assert_eq!(lines[0], "unused-devdep:jest");
1123    }
1124
1125    #[test]
1126    fn compact_unused_enum_member_format() {
1127        let root = PathBuf::from("/project");
1128        let mut results = AnalysisResults::default();
1129        results
1130            .unused_enum_members
1131            .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
1132                path: root.join("src/enums.ts"),
1133                parent_name: "Status".to_string(),
1134                member_name: "Deprecated".to_string(),
1135                kind: MemberKind::EnumMember,
1136                line: 8,
1137                col: 2,
1138            }));
1139
1140        let lines = build_compact_lines(&results, &root);
1141        assert_eq!(
1142            lines[0],
1143            "unused-enum-member:src/enums.ts:8:Status.Deprecated"
1144        );
1145    }
1146
1147    #[test]
1148    fn compact_unused_class_member_format() {
1149        let root = PathBuf::from("/project");
1150        let mut results = AnalysisResults::default();
1151        results
1152            .unused_class_members
1153            .push(UnusedClassMemberFinding::with_actions(UnusedMember {
1154                path: root.join("src/service.ts"),
1155                parent_name: "UserService".to_string(),
1156                member_name: "legacyMethod".to_string(),
1157                kind: MemberKind::ClassMethod,
1158                line: 42,
1159                col: 4,
1160            }));
1161
1162        let lines = build_compact_lines(&results, &root);
1163        assert_eq!(
1164            lines[0],
1165            "unused-class-member:src/service.ts:42:UserService.legacyMethod"
1166        );
1167    }
1168
1169    #[test]
1170    fn compact_unresolved_import_format() {
1171        let root = PathBuf::from("/project");
1172        let mut results = AnalysisResults::default();
1173        results
1174            .unresolved_imports
1175            .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
1176                path: root.join("src/app.ts"),
1177                specifier: "./missing-module".to_string(),
1178                line: 3,
1179                col: 0,
1180                specifier_col: 0,
1181            }));
1182
1183        let lines = build_compact_lines(&results, &root);
1184        assert_eq!(lines[0], "unresolved-import:src/app.ts:3:./missing-module");
1185    }
1186
1187    #[test]
1188    fn compact_unlisted_dep_format() {
1189        let root = PathBuf::from("/project");
1190        let mut results = AnalysisResults::default();
1191        results
1192            .unlisted_dependencies
1193            .push(UnlistedDependencyFinding::with_actions(
1194                UnlistedDependency {
1195                    package_name: "chalk".to_string(),
1196                    imported_from: vec![],
1197                },
1198            ));
1199
1200        let lines = build_compact_lines(&results, &root);
1201        assert_eq!(lines[0], "unlisted-dep:chalk");
1202    }
1203
1204    #[test]
1205    fn compact_duplicate_export_format() {
1206        let root = PathBuf::from("/project");
1207        let mut results = AnalysisResults::default();
1208        results
1209            .duplicate_exports
1210            .push(DuplicateExportFinding::with_actions(DuplicateExport {
1211                export_name: "Config".to_string(),
1212                locations: vec![
1213                    DuplicateLocation {
1214                        path: root.join("src/a.ts"),
1215                        line: 15,
1216                        col: 0,
1217                    },
1218                    DuplicateLocation {
1219                        path: root.join("src/b.ts"),
1220                        line: 30,
1221                        col: 0,
1222                    },
1223                ],
1224            }));
1225
1226        let lines = build_compact_lines(&results, &root);
1227        assert_eq!(lines[0], "duplicate-export:Config");
1228    }
1229
1230    #[test]
1231    fn compact_all_issue_types_produce_lines() {
1232        let root = PathBuf::from("/project");
1233        let results = sample_results(&root);
1234        let lines = build_compact_lines(&results, &root);
1235
1236        assert_eq!(lines.len(), 23);
1237
1238        assert!(lines[0].starts_with("unused-file:"));
1239        assert!(lines[1].starts_with("unused-export:"));
1240        assert!(lines[2].starts_with("unused-type:"));
1241        assert!(lines[3].starts_with("unused-dep:"));
1242        assert!(lines[4].starts_with("unused-devdep:"));
1243        assert!(lines[5].starts_with("unused-optionaldep:"));
1244        assert!(lines[6].starts_with("unused-enum-member:"));
1245        assert!(lines[7].starts_with("unused-class-member:"));
1246        assert!(lines[8].starts_with("unused-store-member:"));
1247        assert!(lines[9].starts_with("unresolved-import:"));
1248        assert!(lines[10].starts_with("unlisted-dep:"));
1249        assert!(lines[11].starts_with("duplicate-export:"));
1250        assert!(lines[12].starts_with("type-only-dep:"));
1251        assert!(lines[13].starts_with("test-only-dep:"));
1252        assert!(lines[14].starts_with("circular-dependency:"));
1253        assert!(lines[15].starts_with("boundary-violation:"));
1254        assert!(lines.iter().any(|l| l.starts_with("unprovided-inject:")));
1255        assert!(lines.iter().any(|l| l.starts_with("unrendered-component:")));
1256        assert!(
1257            lines
1258                .iter()
1259                .any(|l| l.starts_with("unused-component-prop:"))
1260        );
1261        assert!(
1262            lines
1263                .iter()
1264                .any(|l| l.starts_with("unused-component-emit:"))
1265        );
1266        assert!(lines.iter().any(|l| l.starts_with("unused-server-action:")));
1267        assert!(lines.iter().any(|l| l.starts_with("unused-load-data-key:")));
1268    }
1269
1270    #[test]
1271    fn compact_covers_api_and_boundary_variants() {
1272        let root = PathBuf::from("/project");
1273        let mut results = AnalysisResults::default();
1274        results
1275            .private_type_leaks
1276            .push(PrivateTypeLeakFinding::with_actions(PrivateTypeLeak {
1277                path: root.join("src/api.ts"),
1278                export_name: "createApi".to_owned(),
1279                type_name: "InternalShape".to_owned(),
1280                line: 12,
1281                col: 4,
1282                span_start: 100,
1283            }));
1284        results
1285            .circular_dependencies
1286            .push(CircularDependencyFinding::with_actions(
1287                CircularDependency {
1288                    files: vec![
1289                        root.join("packages/a/index.ts"),
1290                        root.join("packages/b/index.ts"),
1291                    ],
1292                    length: 2,
1293                    line: 3,
1294                    col: 0,
1295                    edges: Vec::new(),
1296                    is_cross_package: true,
1297                },
1298            ));
1299        results
1300            .boundary_coverage_violations
1301            .push(BoundaryCoverageViolationFinding::with_actions(
1302                BoundaryCoverageViolation {
1303                    path: root.join("src/unmatched.ts"),
1304                    line: 1,
1305                    col: 0,
1306                },
1307            ));
1308        results
1309            .boundary_call_violations
1310            .push(BoundaryCallViolationFinding::with_actions(
1311                BoundaryCallViolation {
1312                    path: root.join("src/ui/button.ts"),
1313                    line: 20,
1314                    col: 6,
1315                    zone: "ui".to_owned(),
1316                    callee: "child_process.exec".to_owned(),
1317                    pattern: "child_process.*".to_owned(),
1318                },
1319            ));
1320
1321        let lines = build_compact_lines(&results, &root);
1322
1323        assert_eq!(
1324            lines[0],
1325            "private-type-leak:src/api.ts:12:createApi->InternalShape"
1326        );
1327        assert!(lines[1].contains(" (cross-package)"));
1328        assert_eq!(
1329            lines[2],
1330            "boundary-coverage:src/unmatched.ts:1:no matching boundary zone"
1331        );
1332        assert_eq!(
1333            lines[3],
1334            "boundary-call:src/ui/button.ts:20:child_process.exec forbidden in zone ui (pattern child_process.*)"
1335        );
1336    }
1337
1338    #[test]
1339    fn compact_strips_root_prefix_from_paths() {
1340        let root = PathBuf::from("/project");
1341        let mut results = AnalysisResults::default();
1342        results
1343            .unused_files
1344            .push(UnusedFileFinding::with_actions(UnusedFile {
1345                path: PathBuf::from("/project/src/deep/nested/file.ts"),
1346            }));
1347
1348        let lines = build_compact_lines(&results, &root);
1349        assert_eq!(lines[0], "unused-file:src/deep/nested/file.ts");
1350    }
1351
1352    #[test]
1353    fn compact_re_export_tagged_correctly() {
1354        let root = PathBuf::from("/project");
1355        let mut results = AnalysisResults::default();
1356        results
1357            .unused_exports
1358            .push(UnusedExportFinding::with_actions(UnusedExport {
1359                path: root.join("src/index.ts"),
1360                export_name: "reExported".to_string(),
1361                is_type_only: false,
1362                line: 1,
1363                col: 0,
1364                span_start: 0,
1365                is_re_export: true,
1366            }));
1367
1368        let lines = build_compact_lines(&results, &root);
1369        assert_eq!(lines[0], "unused-re-export:src/index.ts:1:reExported");
1370    }
1371
1372    #[test]
1373    fn compact_type_re_export_tagged_correctly() {
1374        let root = PathBuf::from("/project");
1375        let mut results = AnalysisResults::default();
1376        results
1377            .unused_types
1378            .push(UnusedTypeFinding::with_actions(UnusedExport {
1379                path: root.join("src/index.ts"),
1380                export_name: "ReExportedType".to_string(),
1381                is_type_only: true,
1382                line: 3,
1383                col: 0,
1384                span_start: 0,
1385                is_re_export: true,
1386            }));
1387
1388        let lines = build_compact_lines(&results, &root);
1389        assert_eq!(
1390            lines[0],
1391            "unused-re-export-type:src/index.ts:3:ReExportedType"
1392        );
1393    }
1394
1395    #[test]
1396    fn compact_unused_optional_dep_format() {
1397        let root = PathBuf::from("/project");
1398        let mut results = AnalysisResults::default();
1399        results
1400            .unused_optional_dependencies
1401            .push(UnusedOptionalDependencyFinding::with_actions(
1402                UnusedDependency {
1403                    package_name: "fsevents".to_string(),
1404                    location: DependencyLocation::OptionalDependencies,
1405                    path: root.join("package.json"),
1406                    line: 12,
1407                    used_in_workspaces: Vec::new(),
1408                },
1409            ));
1410
1411        let lines = build_compact_lines(&results, &root);
1412        assert_eq!(lines[0], "unused-optionaldep:fsevents");
1413    }
1414
1415    #[test]
1416    fn compact_circular_dependency_format() {
1417        let root = PathBuf::from("/project");
1418        let mut results = AnalysisResults::default();
1419        results
1420            .circular_dependencies
1421            .push(CircularDependencyFinding::with_actions(
1422                CircularDependency {
1423                    files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1424                    length: 2,
1425                    line: 3,
1426                    col: 0,
1427                    edges: Vec::new(),
1428                    is_cross_package: false,
1429                },
1430            ));
1431
1432        let lines = build_compact_lines(&results, &root);
1433        assert_eq!(lines.len(), 1);
1434        assert!(lines[0].starts_with("circular-dependency:src/a.ts:3:"));
1435        assert!(lines[0].contains("src/a.ts"));
1436        assert!(lines[0].contains("src/b.ts"));
1437        assert!(lines[0].contains("\u{2192}"));
1438    }
1439
1440    #[test]
1441    fn compact_circular_dependency_closes_cycle() {
1442        let root = PathBuf::from("/project");
1443        let mut results = AnalysisResults::default();
1444        results
1445            .circular_dependencies
1446            .push(CircularDependencyFinding::with_actions(
1447                CircularDependency {
1448                    files: vec![
1449                        root.join("src/a.ts"),
1450                        root.join("src/b.ts"),
1451                        root.join("src/c.ts"),
1452                    ],
1453                    length: 3,
1454                    line: 1,
1455                    col: 0,
1456                    edges: Vec::new(),
1457                    is_cross_package: false,
1458                },
1459            ));
1460
1461        let lines = build_compact_lines(&results, &root);
1462        let chain_part = lines[0].split(':').next_back().unwrap();
1463        let parts: Vec<&str> = chain_part.split(" \u{2192} ").collect();
1464        assert_eq!(parts.len(), 4);
1465        assert_eq!(parts[0], parts[3]); // first == last (cycle closes)
1466    }
1467
1468    #[test]
1469    fn compact_type_only_dep_format() {
1470        let root = PathBuf::from("/project");
1471        let mut results = AnalysisResults::default();
1472        results
1473            .type_only_dependencies
1474            .push(TypeOnlyDependencyFinding::with_actions(
1475                TypeOnlyDependency {
1476                    package_name: "zod".to_string(),
1477                    path: root.join("package.json"),
1478                    line: 8,
1479                },
1480            ));
1481
1482        let lines = build_compact_lines(&results, &root);
1483        assert_eq!(lines[0], "type-only-dep:zod");
1484    }
1485
1486    #[test]
1487    fn compact_multiple_unused_files() {
1488        let root = PathBuf::from("/project");
1489        let mut results = AnalysisResults::default();
1490        results
1491            .unused_files
1492            .push(UnusedFileFinding::with_actions(UnusedFile {
1493                path: root.join("src/a.ts"),
1494            }));
1495        results
1496            .unused_files
1497            .push(UnusedFileFinding::with_actions(UnusedFile {
1498                path: root.join("src/b.ts"),
1499            }));
1500
1501        let lines = build_compact_lines(&results, &root);
1502        assert_eq!(lines.len(), 2);
1503        assert_eq!(lines[0], "unused-file:src/a.ts");
1504        assert_eq!(lines[1], "unused-file:src/b.ts");
1505    }
1506
1507    #[test]
1508    fn compact_ordering_optional_dep_between_devdep_and_enum() {
1509        let root = PathBuf::from("/project");
1510        let mut results = AnalysisResults::default();
1511        results
1512            .unused_dev_dependencies
1513            .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1514                package_name: "jest".to_string(),
1515                location: DependencyLocation::DevDependencies,
1516                path: root.join("package.json"),
1517                line: 5,
1518                used_in_workspaces: Vec::new(),
1519            }));
1520        results
1521            .unused_optional_dependencies
1522            .push(UnusedOptionalDependencyFinding::with_actions(
1523                UnusedDependency {
1524                    package_name: "fsevents".to_string(),
1525                    location: DependencyLocation::OptionalDependencies,
1526                    path: root.join("package.json"),
1527                    line: 12,
1528                    used_in_workspaces: Vec::new(),
1529                },
1530            ));
1531        results
1532            .unused_enum_members
1533            .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
1534                path: root.join("src/enums.ts"),
1535                parent_name: "Status".to_string(),
1536                member_name: "Deprecated".to_string(),
1537                kind: MemberKind::EnumMember,
1538                line: 8,
1539                col: 2,
1540            }));
1541
1542        let lines = build_compact_lines(&results, &root);
1543        assert_eq!(lines.len(), 3);
1544        assert!(lines[0].starts_with("unused-devdep:"));
1545        assert!(lines[1].starts_with("unused-optionaldep:"));
1546        assert!(lines[2].starts_with("unused-enum-member:"));
1547    }
1548
1549    #[test]
1550    fn compact_path_outside_root_preserved() {
1551        let root = PathBuf::from("/project");
1552        let mut results = AnalysisResults::default();
1553        results
1554            .unused_files
1555            .push(UnusedFileFinding::with_actions(UnusedFile {
1556                path: PathBuf::from("/other/place/file.ts"),
1557            }));
1558
1559        let lines = build_compact_lines(&results, &root);
1560        assert!(lines[0].contains("/other/place/file.ts"));
1561    }
1562}