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