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