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