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