Skip to main content

fallow_api/
compact_output.rs

1use std::path::Path;
2
3use fallow_engine::duplicates::CloneFingerprintSet;
4use fallow_output::normalize_uri;
5use fallow_types::duplicates::DuplicationReport;
6use fallow_types::results::{AnalysisResults, UnusedExport, UnusedMember};
7
8use crate::ResultGroup;
9
10fn relative_path<'a>(path: &'a Path, root: &Path) -> &'a Path {
11    path.strip_prefix(root).unwrap_or(path)
12}
13
14fn compact_path(path: &Path, root: &Path) -> String {
15    normalize_uri(&relative_path(path, root).display().to_string())
16}
17
18fn compact_circular_dependency_line(
19    cycle: &fallow_types::output_dead_code::CircularDependencyFinding,
20    root: &Path,
21) -> String {
22    let mut display_chain: Vec<String> = cycle
23        .cycle
24        .files
25        .iter()
26        .map(|path| compact_path(path, root))
27        .collect();
28    if let Some(first) = display_chain.first() {
29        display_chain.push(first.clone());
30    }
31    let first_file = display_chain.first().map_or("", String::as_str);
32    let cross_pkg_tag = if cycle.cycle.is_cross_package {
33        " (cross-package)"
34    } else {
35        ""
36    };
37    format!(
38        "circular-dependency:{}:{}:{}{}",
39        first_file,
40        cycle.cycle.line,
41        display_chain.join(" \u{2192} "),
42        cross_pkg_tag
43    )
44}
45
46fn compact_re_export_cycle_line(
47    cycle: &fallow_types::output_dead_code::ReExportCycleFinding,
48    root: &Path,
49) -> String {
50    let chain: Vec<String> = cycle
51        .cycle
52        .files
53        .iter()
54        .map(|path| compact_path(path, root))
55        .collect();
56    let first_file = chain.first().map_or("", String::as_str);
57    let kind_tag = match cycle.cycle.kind {
58        fallow_types::results::ReExportCycleKind::SelfLoop => " (self-loop)",
59        fallow_types::results::ReExportCycleKind::MultiNode => "",
60    };
61    format!(
62        "re-export-cycle:{}:{}{}",
63        first_file,
64        chain.join(" <-> "),
65        kind_tag
66    )
67}
68
69fn compact_boundary_violation_line(
70    item: &fallow_types::output_dead_code::BoundaryViolationFinding,
71    root: &Path,
72) -> String {
73    format!(
74        "boundary-violation:{}:{}:{} -> {} ({} -> {})",
75        compact_path(&item.violation.from_path, root),
76        item.violation.line,
77        compact_path(&item.violation.from_path, root),
78        compact_path(&item.violation.to_path, root),
79        item.violation.from_zone,
80        item.violation.to_zone,
81    )
82}
83
84fn compact_boundary_coverage_line(
85    item: &fallow_types::output_dead_code::BoundaryCoverageViolationFinding,
86    root: &Path,
87) -> String {
88    format!(
89        "boundary-coverage:{}:{}:no matching boundary zone",
90        compact_path(&item.violation.path, root),
91        item.violation.line,
92    )
93}
94
95fn compact_boundary_call_line(
96    item: &fallow_types::output_dead_code::BoundaryCallViolationFinding,
97    root: &Path,
98) -> String {
99    format!(
100        "boundary-call:{}:{}:{} forbidden in zone {} (pattern {})",
101        compact_path(&item.violation.path, root),
102        item.violation.line,
103        item.violation.callee,
104        item.violation.zone,
105        item.violation.pattern,
106    )
107}
108
109fn compact_stale_suppression_line(
110    item: &fallow_types::results::StaleSuppression,
111    root: &Path,
112) -> String {
113    format!(
114        "stale-suppression:{}:{}:{}",
115        compact_path(&item.path, root),
116        item.line,
117        item.display_message(),
118    )
119}
120
121fn compact_catalog_reference_line(
122    item: &fallow_types::output_dead_code::UnresolvedCatalogReferenceFinding,
123    root: &Path,
124) -> String {
125    format!(
126        "unresolved-catalog-reference:{}:{}:{}:{}",
127        compact_path(&item.reference.path, root),
128        item.reference.line,
129        item.reference.catalog_name,
130        item.reference.entry_name,
131    )
132}
133
134fn compact_unused_override_line(
135    item: &fallow_types::output_dead_code::UnusedDependencyOverrideFinding,
136    root: &Path,
137) -> String {
138    format!(
139        "unused-dependency-override:{}:{}:{}:{}",
140        compact_path(&item.entry.path, root),
141        item.entry.line,
142        item.entry.source.as_label(),
143        item.entry.raw_key,
144    )
145}
146
147fn compact_misconfigured_override_line(
148    item: &fallow_types::output_dead_code::MisconfiguredDependencyOverrideFinding,
149    root: &Path,
150) -> String {
151    format!(
152        "misconfigured-dependency-override:{}:{}:{}:{}",
153        compact_path(&item.entry.path, root),
154        item.entry.line,
155        item.entry.source.as_label(),
156        item.entry.raw_key,
157    )
158}
159
160/// Build compact output lines for analysis results.
161/// Each issue is represented as a single `prefix:details` line.
162pub fn build_compact_lines(results: &AnalysisResults, root: &Path) -> Vec<String> {
163    CompactLineBuilder::new(results, root).build()
164}
165
166struct CompactLineBuilder<'a> {
167    lines: Vec<String>,
168    results: &'a AnalysisResults,
169    root: &'a Path,
170}
171
172impl<'a> CompactLineBuilder<'a> {
173    fn new(results: &'a AnalysisResults, root: &'a Path) -> Self {
174        Self {
175            lines: Vec::new(),
176            results,
177            root,
178        }
179    }
180
181    fn build(mut self) -> Vec<String> {
182        self.push_core_lines();
183        self.push_unused_dependency_lines();
184        self.push_member_lines();
185        self.push_secondary_dependency_lines();
186        self.push_graph_lines();
187        self.push_workspace_lines();
188        self.lines
189    }
190
191    fn rel(&self, path: &Path) -> String {
192        compact_path(path, self.root)
193    }
194
195    fn unused_export_line(&self, export: &UnusedExport) -> String {
196        let tag = if export.is_re_export {
197            "unused-re-export"
198        } else {
199            "unused-export"
200        };
201        format!(
202            "{}:{}:{}:{}",
203            tag,
204            self.rel(&export.path),
205            export.line,
206            export.export_name
207        )
208    }
209
210    fn unused_type_line(&self, export: &UnusedExport) -> String {
211        let tag = if export.is_re_export {
212            "unused-re-export-type"
213        } else {
214            "unused-type"
215        };
216        format!(
217            "{}:{}:{}:{}",
218            tag,
219            self.rel(&export.path),
220            export.line,
221            export.export_name
222        )
223    }
224
225    fn compact_member(&self, member: &UnusedMember, kind: &str) -> String {
226        format!(
227            "{}:{}:{}:{}.{}",
228            kind,
229            self.rel(&member.path),
230            member.line,
231            member.parent_name,
232            member.member_name
233        )
234    }
235
236    fn push_core_lines(&mut self) {
237        for file in &self.results.unused_files {
238            self.lines
239                .push(format!("unused-file:{}", self.rel(&file.file.path)));
240        }
241        for export in &self.results.unused_exports {
242            self.lines.push(self.unused_export_line(&export.export));
243        }
244        for export in &self.results.unused_types {
245            self.lines.push(self.unused_type_line(&export.export));
246        }
247        for leak in &self.results.private_type_leaks {
248            self.lines.push(format!(
249                "private-type-leak:{}:{}:{}->{}",
250                self.rel(&leak.leak.path),
251                leak.leak.line,
252                leak.leak.export_name,
253                leak.leak.type_name
254            ));
255        }
256    }
257
258    fn push_unused_dependency_lines(&mut self) {
259        for dep in &self.results.unused_dependencies {
260            self.lines
261                .push(format!("unused-dep:{}", dep.dep.package_name));
262        }
263        for dep in &self.results.unused_dev_dependencies {
264            self.lines
265                .push(format!("unused-devdep:{}", dep.dep.package_name));
266        }
267        for dep in &self.results.unused_optional_dependencies {
268            self.lines
269                .push(format!("unused-optionaldep:{}", dep.dep.package_name));
270        }
271    }
272
273    fn push_member_lines(&mut self) {
274        for member in &self.results.unused_enum_members {
275            self.lines
276                .push(self.compact_member(&member.member, "unused-enum-member"));
277        }
278        for member in &self.results.unused_class_members {
279            self.lines
280                .push(self.compact_member(&member.member, "unused-class-member"));
281        }
282        for member in &self.results.unused_store_members {
283            self.lines
284                .push(self.compact_member(&member.member, "unused-store-member"));
285        }
286        for import in &self.results.unresolved_imports {
287            self.lines.push(format!(
288                "unresolved-import:{}:{}:{}",
289                self.rel(&import.import.path),
290                import.import.line,
291                import.import.specifier
292            ));
293        }
294    }
295
296    fn push_secondary_dependency_lines(&mut self) {
297        for dep in &self.results.unlisted_dependencies {
298            self.lines
299                .push(format!("unlisted-dep:{}", dep.dep.package_name));
300        }
301        for dup in &self.results.duplicate_exports {
302            self.lines
303                .push(format!("duplicate-export:{}", dup.export.export_name));
304        }
305        for dep in &self.results.type_only_dependencies {
306            self.lines
307                .push(format!("type-only-dep:{}", dep.dep.package_name));
308        }
309        for dep in &self.results.test_only_dependencies {
310            self.lines
311                .push(format!("test-only-dep:{}", dep.dep.package_name));
312        }
313        for dep in &self.results.dev_dependencies_in_production {
314            self.lines
315                .push(format!("dev-dep-in-prod:{}", dep.dep.package_name));
316        }
317    }
318
319    fn push_graph_lines(&mut self) {
320        self.push_structure_lines();
321        self.push_framework_lines();
322        self.push_component_lines();
323        self.push_route_lines();
324        self.push_suppression_lines();
325    }
326
327    fn push_structure_lines(&mut self) {
328        for cycle in &self.results.circular_dependencies {
329            self.lines
330                .push(compact_circular_dependency_line(cycle, self.root));
331        }
332        for cycle in &self.results.re_export_cycles {
333            self.lines
334                .push(compact_re_export_cycle_line(cycle, self.root));
335        }
336        for violation in &self.results.boundary_violations {
337            self.lines
338                .push(compact_boundary_violation_line(violation, self.root));
339        }
340        for violation in &self.results.boundary_coverage_violations {
341            self.lines
342                .push(compact_boundary_coverage_line(violation, self.root));
343        }
344        for violation in &self.results.boundary_call_violations {
345            self.lines
346                .push(compact_boundary_call_line(violation, self.root));
347        }
348        for violation in &self.results.policy_violations {
349            self.lines.push(format!(
350                "policy-violation:{}:{}:{} banned by {}/{}",
351                self.rel(&violation.violation.path),
352                violation.violation.line,
353                violation.violation.matched,
354                violation.violation.pack,
355                violation.violation.rule_id,
356            ));
357        }
358    }
359
360    fn push_framework_lines(&mut self) {
361        for finding in &self.results.invalid_client_exports {
362            self.lines.push(format!(
363                "invalid-client-export:{}:{}:{} (from \"{}\")",
364                self.rel(&finding.export.path),
365                finding.export.line,
366                finding.export.export_name,
367                finding.export.directive,
368            ));
369        }
370        for finding in &self.results.mixed_client_server_barrels {
371            self.lines.push(format!(
372                "mixed-client-server-barrel:{}:{}:{} (server-only \"{}\")",
373                self.rel(&finding.barrel.path),
374                finding.barrel.line,
375                finding.barrel.client_origin,
376                finding.barrel.server_origin,
377            ));
378        }
379        for finding in &self.results.misplaced_directives {
380            self.lines.push(format!(
381                "misplaced-directive:{}:{}:{}",
382                self.rel(&finding.directive_site.path),
383                finding.directive_site.line,
384                finding.directive_site.directive,
385            ));
386        }
387        for finding in &self.results.unprovided_injects {
388            self.lines.push(format!(
389                "unprovided-inject:{}:{}:{}",
390                self.rel(&finding.inject.path),
391                finding.inject.line,
392                finding.inject.key_name,
393            ));
394        }
395    }
396
397    fn push_component_lines(&mut self) {
398        self.push_component_member_lines();
399        self.push_component_framework_lines();
400    }
401
402    /// Push compact lines for unrendered components, props, emits, inputs, and outputs.
403    fn push_component_member_lines(&mut self) {
404        for finding in &self.results.unrendered_components {
405            self.lines.push(format!(
406                "unrendered-component:{}:{}:{}",
407                self.rel(&finding.component.path),
408                finding.component.line,
409                finding.component.component_name,
410            ));
411        }
412        for finding in &self.results.unused_component_props {
413            self.lines.push(format!(
414                "unused-component-prop:{}:{}:{}",
415                self.rel(&finding.prop.path),
416                finding.prop.line,
417                finding.prop.prop_name,
418            ));
419        }
420        for finding in &self.results.unused_component_emits {
421            self.lines.push(format!(
422                "unused-component-emit:{}:{}:{}",
423                self.rel(&finding.emit.path),
424                finding.emit.line,
425                finding.emit.emit_name,
426            ));
427        }
428        for finding in &self.results.unused_component_inputs {
429            self.lines.push(format!(
430                "unused-component-input:{}:{}:{}",
431                self.rel(&finding.input.path),
432                finding.input.line,
433                finding.input.input_name,
434            ));
435        }
436        for finding in &self.results.unused_component_outputs {
437            self.lines.push(format!(
438                "unused-component-output:{}:{}:{}",
439                self.rel(&finding.output.path),
440                finding.output.line,
441                finding.output.output_name,
442            ));
443        }
444    }
445
446    /// Push compact lines for Svelte events, server actions, and load-data keys.
447    fn push_component_framework_lines(&mut self) {
448        for finding in &self.results.unused_svelte_events {
449            self.lines.push(format!(
450                "unused-svelte-event:{}:{}:{}",
451                self.rel(&finding.event.path),
452                finding.event.line,
453                finding.event.event_name,
454            ));
455        }
456        for finding in &self.results.unused_server_actions {
457            self.lines.push(format!(
458                "unused-server-action:{}:{}:{}",
459                self.rel(&finding.action.path),
460                finding.action.line,
461                finding.action.action_name,
462            ));
463        }
464        for finding in &self.results.unused_load_data_keys {
465            self.lines.push(format!(
466                "unused-load-data-key:{}:{}:{}",
467                self.rel(&finding.key.path),
468                finding.key.line,
469                finding.key.key_name,
470            ));
471        }
472    }
473
474    fn push_route_lines(&mut self) {
475        for finding in &self.results.route_collisions {
476            self.lines.push(format!(
477                "route-collision:{}:{} (url {})",
478                self.rel(&finding.collision.path),
479                finding.collision.line,
480                finding.collision.url,
481            ));
482        }
483        for finding in &self.results.dynamic_segment_name_conflicts {
484            self.lines.push(format!(
485                "dynamic-segment-name-conflict:{}:{} ({} at {})",
486                self.rel(&finding.conflict.path),
487                finding.conflict.line,
488                finding.conflict.conflicting_segments.join(" vs "),
489                finding.conflict.position,
490            ));
491        }
492    }
493
494    fn push_suppression_lines(&mut self) {
495        for suppression in &self.results.stale_suppressions {
496            self.lines
497                .push(compact_stale_suppression_line(suppression, self.root));
498        }
499    }
500
501    fn push_workspace_lines(&mut self) {
502        for entry in &self.results.unused_catalog_entries {
503            self.lines.push(format!(
504                "unused-catalog-entry:{}:{}:{}:{}",
505                self.rel(&entry.entry.path),
506                entry.entry.line,
507                entry.entry.catalog_name,
508                entry.entry.entry_name,
509            ));
510        }
511        for group in &self.results.empty_catalog_groups {
512            self.lines.push(format!(
513                "empty-catalog-group:{}:{}:{}",
514                self.rel(&group.group.path),
515                group.group.line,
516                group.group.catalog_name,
517            ));
518        }
519        for finding in &self.results.unresolved_catalog_references {
520            self.lines
521                .push(compact_catalog_reference_line(finding, self.root));
522        }
523        for finding in &self.results.unused_dependency_overrides {
524            self.lines
525                .push(compact_unused_override_line(finding, self.root));
526        }
527        for finding in &self.results.misconfigured_dependency_overrides {
528            self.lines
529                .push(compact_misconfigured_override_line(finding, self.root));
530        }
531    }
532}
533
534/// Build grouped compact output lines, each prefixed with the group key.
535///
536/// Format: `group-key\tissue-tag:details`
537#[must_use]
538pub fn build_grouped_compact_lines(groups: &[ResultGroup], root: &Path) -> Vec<String> {
539    groups
540        .iter()
541        .flat_map(|group| {
542            build_compact_lines(&group.results, root)
543                .into_iter()
544                .map(|line| format!("{}\t{line}", group.key))
545        })
546        .collect()
547}
548
549/// Build compact output lines for health results.
550#[must_use]
551pub fn build_health_compact_lines(
552    report: &fallow_output::HealthReport,
553    root: &Path,
554) -> Vec<String> {
555    let mut lines = Vec::new();
556    push_health_score_compact(&mut lines, report);
557    push_vital_signs_compact(&mut lines, report);
558    push_health_findings_compact(&mut lines, &report.findings, root);
559    push_styling_findings_compact(&mut lines, &report.styling_findings, root);
560    push_threshold_overrides_compact(&mut lines, &report.threshold_overrides, root);
561    push_file_scores_compact(&mut lines, &report.file_scores, root);
562    push_coverage_gaps_compact(&mut lines, report, root);
563    push_runtime_sections_compact(&mut lines, report, root);
564    push_hotspots_compact(&mut lines, &report.hotspots, root);
565    push_health_trend_compact(&mut lines, report);
566    push_refactoring_targets_compact(&mut lines, &report.targets, root);
567    lines
568}
569
570fn push_styling_findings_compact(
571    lines: &mut Vec<String>,
572    findings: &[fallow_output::StylingFinding],
573    root: &Path,
574) {
575    for finding in findings {
576        let relative = compact_path(Path::new(&finding.path), root);
577        let severity = match finding.effective_severity {
578            fallow_output::StylingFindingSeverity::Error => "error",
579            fallow_output::StylingFindingSeverity::Warn => "warn",
580        };
581        let value = compact_field_value(&finding.value);
582        lines.push(format!(
583            "{}:{}:{}:{}:severity={},value={}",
584            finding.code, relative, finding.line, finding.sub_kind, severity, value
585        ));
586    }
587}
588
589fn compact_field_value(value: &str) -> String {
590    value
591        .replace([':', ',', '\n', '\r'], " ")
592        .split_whitespace()
593        .collect::<Vec<_>>()
594        .join(" ")
595}
596
597fn push_threshold_overrides_compact(
598    lines: &mut Vec<String>,
599    entries: &[fallow_output::ThresholdOverrideState],
600    root: &Path,
601) {
602    for entry in entries {
603        let status = match entry.status {
604            fallow_output::ThresholdOverrideStatus::Active => "active",
605            fallow_output::ThresholdOverrideStatus::Stale => "stale",
606            fallow_output::ThresholdOverrideStatus::Insufficient => "insufficient",
607            fallow_output::ThresholdOverrideStatus::NoMatch => "no_match",
608        };
609        let target = entry.path.as_ref().map_or_else(
610            || "no-match".to_string(),
611            |path| entry.target_label(&compact_path(path, root)),
612        );
613        let dimension = threshold_override_dimension_label(entry.dimension);
614        let metrics = entry.metrics.map_or(String::new(), |metrics| {
615            let crap = metrics
616                .crap
617                .map_or(String::new(), |value| format!(",crap={value:.1}"));
618            let line_count = metrics
619                .line_count
620                .map_or(String::new(), |value| format!(",lines={value}"));
621            format!(
622                ",cyclomatic={},cognitive={}{}{}",
623                metrics.cyclomatic, metrics.cognitive, line_count, crap
624            )
625        });
626        let outstanding = if entry.outstanding.is_empty() {
627            String::new()
628        } else {
629            format!(
630                ",outstanding={}",
631                entry
632                    .outstanding
633                    .iter()
634                    .map(|value| threshold_override_dimension_label(*value))
635                    .collect::<Vec<_>>()
636                    .join("|")
637            )
638        };
639        lines.push(format!(
640            "threshold-override:{}:{}:{}:{}{}{}",
641            entry.override_index, dimension, status, target, metrics, outstanding
642        ));
643    }
644}
645
646fn threshold_override_dimension_label(
647    dimension: fallow_output::ThresholdOverrideDimension,
648) -> &'static str {
649    match dimension {
650        fallow_output::ThresholdOverrideDimension::Complexity => "complexity",
651        fallow_output::ThresholdOverrideDimension::Crap => "crap",
652    }
653}
654
655fn push_health_score_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
656    if let Some(ref hs) = report.health_score {
657        lines.push(format!("health-score:{:.1}:{}", hs.score, hs.grade));
658    }
659}
660
661fn push_vital_signs_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
662    if let Some(ref vs) = report.vital_signs {
663        let mut parts = Vec::new();
664        if vs.total_loc > 0 {
665            parts.push(format!("total_loc={}", vs.total_loc));
666        }
667        parts.push(format!("avg_cyclomatic={:.1}", vs.avg_cyclomatic));
668        parts.push(format!("p90_cyclomatic={}", vs.p90_cyclomatic));
669        if let Some(v) = vs.dead_file_pct {
670            parts.push(format!("dead_file_pct={v:.1}"));
671        }
672        if let Some(v) = vs.dead_export_pct {
673            parts.push(format!("dead_export_pct={v:.1}"));
674        }
675        if let Some(v) = vs.maintainability_avg {
676            parts.push(format!("maintainability_avg={v:.1}"));
677        }
678        if let Some(v) = vs.hotspot_count {
679            parts.push(format!("hotspot_count={v}"));
680        }
681        if let Some(v) = vs.circular_dep_count {
682            parts.push(format!("circular_dep_count={v}"));
683        }
684        if let Some(v) = vs.unused_dep_count {
685            parts.push(format!("unused_dep_count={v}"));
686        }
687        lines.push(format!("vital-signs:{}", parts.join(",")));
688    }
689}
690
691/// Serde-name mirror of `ExceededThreshold` for the compact line grammar.
692fn exceeded_compact_label(exceeded: fallow_output::ExceededThreshold) -> &'static str {
693    match exceeded {
694        fallow_output::ExceededThreshold::Cyclomatic => "cyclomatic",
695        fallow_output::ExceededThreshold::Cognitive => "cognitive",
696        fallow_output::ExceededThreshold::Both => "both",
697        fallow_output::ExceededThreshold::Crap => "crap",
698        fallow_output::ExceededThreshold::CyclomaticCrap => "cyclomatic_crap",
699        fallow_output::ExceededThreshold::CognitiveCrap => "cognitive_crap",
700        fallow_output::ExceededThreshold::All => "all",
701    }
702}
703
704fn push_health_findings_compact(
705    lines: &mut Vec<String>,
706    findings: &[fallow_output::HealthFinding],
707    root: &Path,
708) {
709    for finding in findings {
710        let relative = compact_path(&finding.path, root);
711        let severity = match finding.severity {
712            fallow_output::FindingSeverity::Critical => "critical",
713            fallow_output::FindingSeverity::High => "high",
714            fallow_output::FindingSeverity::Moderate => "moderate",
715        };
716        let crap_suffix = match finding.crap {
717            Some(crap) => {
718                let coverage = finding
719                    .coverage_pct
720                    .map(|pct| format!(",coverage_pct={pct:.1}"))
721                    .unwrap_or_default();
722                format!(",crap={crap:.1}{coverage}")
723            }
724            None => String::new(),
725        };
726        // The `high-complexity:` row prefix is deliberately kept even for a
727        // CRAP-only breach (other machine formats route that to
728        // `fallow/high-crap-score`): renaming a line prefix breaks grep-based CI
729        // consumers, and `exceeded=` carries the dimension instead (issue #2163).
730        lines.push(format!(
731            "high-complexity:{}:{}:{}:cyclomatic={},cognitive={},severity={},exceeded={}{}",
732            relative,
733            finding.line,
734            finding.name,
735            finding.cyclomatic,
736            finding.cognitive,
737            severity,
738            exceeded_compact_label(finding.exceeded),
739            crap_suffix,
740        ));
741    }
742}
743
744fn push_file_scores_compact(
745    lines: &mut Vec<String>,
746    scores: &[fallow_output::FileHealthScore],
747    root: &Path,
748) {
749    for score in scores {
750        let relative = compact_path(&score.path, root);
751        lines.push(format!(
752            "file-score:{}:mi={:.1},fan_in={},fan_out={},dead={:.2},density={:.2},crap_max={:.1},crap_above={}",
753            relative,
754            score.maintainability_index,
755            score.fan_in,
756            score.fan_out,
757            score.dead_code_ratio,
758            score.complexity_density,
759            score.crap_max,
760            score.crap_above_threshold,
761        ));
762    }
763}
764
765fn push_coverage_gaps_compact(
766    lines: &mut Vec<String>,
767    report: &fallow_output::HealthReport,
768    root: &Path,
769) {
770    if let Some(ref gaps) = report.coverage_gaps {
771        lines.push(format!(
772            "coverage-gap-summary:runtime_files={},covered_files={},file_coverage_pct={:.1},untested_files={},untested_exports={}",
773            gaps.summary.runtime_files,
774            gaps.summary.covered_files,
775            gaps.summary.file_coverage_pct,
776            gaps.summary.untested_files,
777            gaps.summary.untested_exports,
778        ));
779        for item in &gaps.files {
780            let relative = compact_path(&item.file.path, root);
781            lines.push(format!(
782                "untested-file:{}:value_exports={}",
783                relative, item.file.value_export_count,
784            ));
785        }
786        for item in &gaps.exports {
787            let relative = compact_path(&item.export.path, root);
788            lines.push(format!(
789                "untested-export:{}:{}:{}",
790                relative, item.export.line, item.export.export_name,
791            ));
792        }
793    }
794}
795
796fn push_runtime_sections_compact(
797    lines: &mut Vec<String>,
798    report: &fallow_output::HealthReport,
799    root: &Path,
800) {
801    if let Some(ref production) = report.runtime_coverage {
802        lines.extend(build_runtime_coverage_compact_lines(production, root));
803    }
804    if let Some(ref intelligence) = report.coverage_intelligence {
805        lines.extend(build_coverage_intelligence_compact_lines(
806            intelligence,
807            root,
808        ));
809    }
810}
811
812fn compact_ownership_suffix(ownership: Option<&fallow_output::OwnershipMetrics>) -> String {
813    ownership.map_or_else(String::new, |o| {
814        let mut parts = vec![
815            format!("bus={}", o.bus_factor),
816            format!("contributors={}", o.contributor_count),
817            format!("top={}", o.top_contributor.identifier),
818            format!("top_share={:.3}", o.top_contributor.share),
819        ];
820        if let Some(owner) = &o.declared_owner {
821            parts.push(format!("owner={owner}"));
822        }
823        if let Some(unowned) = o.unowned {
824            parts.push(format!("unowned={unowned}"));
825        }
826        let state = match o.ownership_state {
827            fallow_output::OwnershipState::Active => "active",
828            fallow_output::OwnershipState::Unowned => "unowned",
829            fallow_output::OwnershipState::DeclaredInactive => "declared_inactive",
830            fallow_output::OwnershipState::Drifting => "drifting",
831        };
832        parts.push(format!("ownership_state={state}"));
833        if o.drift {
834            parts.push("drift=true".to_string());
835        }
836        format!(",{}", parts.join(","))
837    })
838}
839
840fn push_hotspots_compact(
841    lines: &mut Vec<String>,
842    hotspots: &[fallow_output::HotspotFinding],
843    root: &Path,
844) {
845    for entry in hotspots {
846        let relative = compact_path(&entry.path, root);
847        let ownership_suffix = compact_ownership_suffix(entry.ownership.as_ref());
848        lines.push(format!(
849            "hotspot:{}:score={:.1},commits={},churn={},density={:.2},fan_in={},trend={}{}",
850            relative,
851            entry.score,
852            entry.commits,
853            entry.lines_added + entry.lines_deleted,
854            entry.complexity_density,
855            entry.fan_in,
856            entry.trend,
857            ownership_suffix,
858        ));
859    }
860}
861
862fn push_health_trend_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
863    if let Some(ref trend) = report.health_trend {
864        lines.push(format!(
865            "trend:overall:direction={}",
866            trend.overall_direction.label()
867        ));
868        for m in &trend.metrics {
869            lines.push(format!(
870                "trend:{}:previous={:.1},current={:.1},delta={:+.1},direction={}",
871                m.name,
872                m.previous,
873                m.current,
874                m.delta,
875                m.direction.label(),
876            ));
877        }
878    }
879}
880
881fn push_refactoring_targets_compact(
882    lines: &mut Vec<String>,
883    targets: &[fallow_output::RefactoringTargetFinding],
884    root: &Path,
885) {
886    for target in targets {
887        let relative = compact_path(&target.path, root);
888        let category = target.category.compact_label();
889        let effort = target.effort.label();
890        let confidence = target.confidence.label();
891        lines.push(format!(
892            "refactoring-target:{}:priority={:.1},efficiency={:.1},category={},effort={},confidence={}:{}",
893            relative,
894            target.priority,
895            target.efficiency,
896            category,
897            effort,
898            confidence,
899            target.recommendation,
900        ));
901    }
902}
903
904fn build_runtime_coverage_compact_lines(
905    production: &fallow_output::RuntimeCoverageReport,
906    root: &Path,
907) -> Vec<String> {
908    let mut lines = vec![format!(
909        "runtime-coverage-summary:functions_tracked={},functions_hit={},functions_unhit={},functions_untracked={},coverage_percent={:.1},trace_count={},period_days={},deployments_seen={}",
910        production.summary.functions_tracked,
911        production.summary.functions_hit,
912        production.summary.functions_unhit,
913        production.summary.functions_untracked,
914        production.summary.coverage_percent,
915        production.summary.trace_count,
916        production.summary.period_days,
917        production.summary.deployments_seen,
918    )];
919    for finding in &production.findings {
920        let relative = compact_path(&finding.path, root);
921        let invocations = finding
922            .invocations
923            .map_or_else(|| "null".to_owned(), |hits| hits.to_string());
924        lines.push(format!(
925            "runtime-coverage:{}:{}:{}:id={},verdict={},invocations={},confidence={}",
926            relative,
927            finding.line,
928            finding.function,
929            finding.id,
930            finding.verdict,
931            invocations,
932            finding.confidence,
933        ));
934    }
935    for entry in &production.hot_paths {
936        let relative = compact_path(&entry.path, root);
937        lines.push(format!(
938            "production-hot-path:{}:{}:{}:id={},invocations={},percentile={}",
939            relative, entry.line, entry.function, entry.id, entry.invocations, entry.percentile,
940        ));
941    }
942    lines
943}
944
945fn build_coverage_intelligence_compact_lines(
946    intelligence: &fallow_output::CoverageIntelligenceReport,
947    root: &Path,
948) -> Vec<String> {
949    let mut lines = vec![format!(
950        "coverage-intelligence-summary:verdict={},findings={},risky_changes={},high_confidence_deletes={},review_required={},refactor_carefully={},skipped_ambiguous_matches={}",
951        intelligence.verdict,
952        intelligence.summary.findings,
953        intelligence.summary.risky_changes,
954        intelligence.summary.high_confidence_deletes,
955        intelligence.summary.review_required,
956        intelligence.summary.refactor_carefully,
957        intelligence.summary.skipped_ambiguous_matches,
958    )];
959    for finding in &intelligence.findings {
960        let relative = compact_path(&finding.path, root);
961        let identity = finding.identity.as_deref().unwrap_or("-");
962        let signals = finding
963            .signals
964            .iter()
965            .map(ToString::to_string)
966            .collect::<Vec<_>>()
967            .join("+");
968        lines.push(format!(
969            "coverage-intelligence:{}:{}:{}:id={},verdict={},recommendation={},confidence={},signals={}",
970            relative,
971            finding.line,
972            identity,
973            finding.id,
974            finding.verdict,
975            finding.recommendation,
976            finding.confidence,
977            signals,
978        ));
979    }
980    lines
981}
982
983/// Build compact output lines for duplication results.
984#[must_use]
985pub fn build_duplication_compact_lines(report: &DuplicationReport, root: &Path) -> Vec<String> {
986    let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
987    let mut lines = Vec::new();
988    for (index, group) in report.clone_groups.iter().enumerate() {
989        let fingerprint = fingerprints.fingerprint_for_group(group);
990        for instance in &group.instances {
991            lines.push(format!(
992                "code-duplication:{}:{}-{}:fingerprint={},group={},tokens={},lines={},instances={}",
993                compact_path(&instance.file, root),
994                instance.start_line,
995                instance.end_line,
996                fingerprint,
997                index + 1,
998                group.token_count,
999                group.line_count,
1000                group.instances.len(),
1001            ));
1002        }
1003    }
1004    lines
1005}
1006
1007#[cfg(test)]
1008mod tests {
1009    use std::path::PathBuf;
1010
1011    use fallow_types::duplicates::{CloneGroup, CloneInstance, DuplicationStats};
1012    use fallow_types::output_dead_code::UnusedFileFinding;
1013    use fallow_types::results::{AnalysisResults, UnusedFile};
1014
1015    use super::*;
1016
1017    #[test]
1018    fn compact_cycles_preserve_empty_self_loop_and_cross_package_output() {
1019        use fallow_types::output_dead_code::{CircularDependencyFinding, ReExportCycleFinding};
1020        use fallow_types::results::{CircularDependency, ReExportCycle, ReExportCycleKind};
1021
1022        let root = Path::new("/project");
1023        for (files, cross_package, expected) in [
1024            (vec![], false, "circular-dependency::7:"),
1025            (
1026                vec![root.join("src/a.ts")],
1027                false,
1028                "circular-dependency:src/a.ts:7:src/a.ts → src/a.ts",
1029            ),
1030            (
1031                vec![root.join("src/a.ts"), root.join("src/b.ts")],
1032                true,
1033                "circular-dependency:src/a.ts:7:src/a.ts → src/b.ts → src/a.ts (cross-package)",
1034            ),
1035        ] {
1036            let mut results = AnalysisResults::default();
1037            results
1038                .circular_dependencies
1039                .push(CircularDependencyFinding {
1040                    cycle: CircularDependency {
1041                        length: files.len(),
1042                        files,
1043                        line: 7,
1044                        col: 0,
1045                        edges: vec![],
1046                        is_cross_package: cross_package,
1047                    },
1048                    actions: vec![],
1049                    introduced: None,
1050                });
1051            assert_eq!(build_compact_lines(&results, root), vec![expected]);
1052        }
1053        for (files, kind, expected) in [
1054            (vec![], ReExportCycleKind::MultiNode, "re-export-cycle::"),
1055            (
1056                vec![root.join("src/a.ts")],
1057                ReExportCycleKind::SelfLoop,
1058                "re-export-cycle:src/a.ts:src/a.ts (self-loop)",
1059            ),
1060            (
1061                vec![root.join("src/a.ts"), root.join("src/b.ts")],
1062                ReExportCycleKind::MultiNode,
1063                "re-export-cycle:src/a.ts:src/a.ts <-> src/b.ts",
1064            ),
1065        ] {
1066            let mut results = AnalysisResults::default();
1067            results.re_export_cycles.push(ReExportCycleFinding {
1068                cycle: ReExportCycle { files, kind },
1069                actions: vec![],
1070                introduced: None,
1071            });
1072            assert_eq!(build_compact_lines(&results, root), vec![expected]);
1073        }
1074    }
1075
1076    #[test]
1077    fn compact_unused_file_format_uses_relative_paths() {
1078        let root = PathBuf::from("/project");
1079        let mut results = AnalysisResults::default();
1080        results
1081            .unused_files
1082            .push(UnusedFileFinding::with_actions(UnusedFile {
1083                path: root.join("src/dead.ts"),
1084            }));
1085
1086        let lines = build_compact_lines(&results, &root);
1087
1088        assert_eq!(lines, vec!["unused-file:src/dead.ts"]);
1089    }
1090
1091    #[test]
1092    fn grouped_compact_prefixes_each_issue_with_group_key() {
1093        let root = PathBuf::from("/project");
1094        let mut results = AnalysisResults::default();
1095        results
1096            .unused_files
1097            .push(UnusedFileFinding::with_actions(UnusedFile {
1098                path: root.join("src/dead.ts"),
1099            }));
1100        let groups = vec![ResultGroup {
1101            key: "team-a".to_owned(),
1102            owners: Some(vec!["@team-a".to_owned()]),
1103            results,
1104        }];
1105
1106        let lines = build_grouped_compact_lines(&groups, &root);
1107
1108        assert_eq!(lines, vec!["team-a\tunused-file:src/dead.ts"]);
1109    }
1110
1111    #[test]
1112    fn duplication_compact_lines_include_stable_group_context() {
1113        let root = PathBuf::from("/project");
1114        let report = DuplicationReport {
1115            clone_groups: vec![CloneGroup {
1116                instances: vec![CloneInstance {
1117                    file: root.join("src/a.ts"),
1118                    start_line: 2,
1119                    end_line: 6,
1120                    start_col: 0,
1121                    end_col: 10,
1122                    fragment: "const duplicated = true;".to_owned(),
1123                }],
1124                token_count: 12,
1125                line_count: 5,
1126                similarity: None,
1127            }],
1128            clone_families: Vec::new(),
1129            mirrored_directories: Vec::new(),
1130            stats: DuplicationStats::default(),
1131        };
1132
1133        let lines = build_duplication_compact_lines(&report, &root);
1134
1135        assert_eq!(lines.len(), 1);
1136        assert!(lines[0].starts_with("code-duplication:src/a.ts:2-6:fingerprint="));
1137        assert!(lines[0].contains(",group=1,tokens=12,lines=5,instances=1"));
1138    }
1139
1140    #[test]
1141    fn health_compact_lines_include_score_and_vital_signs() {
1142        let root = PathBuf::from("/project");
1143        let report = fallow_output::HealthReport {
1144            health_score: Some(fallow_output::HealthScore {
1145                formula_version: 1,
1146                score: 91.2,
1147                grade: "A",
1148                penalties: fallow_output::HealthScorePenalties {
1149                    dead_files: None,
1150                    dead_exports: None,
1151                    complexity: 0.0,
1152                    p90_complexity: 0.0,
1153                    maintainability: None,
1154                    hotspots: None,
1155                    unused_deps: None,
1156                    circular_deps: None,
1157                    unit_size: None,
1158                    coupling: None,
1159                    duplication: None,
1160                    prop_drilling: None,
1161                },
1162            }),
1163            vital_signs: Some(fallow_output::VitalSigns {
1164                total_loc: 120,
1165                avg_cyclomatic: 3.4,
1166                p90_cyclomatic: 8,
1167                ..Default::default()
1168            }),
1169            ..Default::default()
1170        };
1171
1172        let lines = build_health_compact_lines(&report, &root);
1173
1174        assert_eq!(lines[0], "health-score:91.2:A");
1175        assert_eq!(
1176            lines[1],
1177            "vital-signs:total_loc=120,avg_cyclomatic=3.4,p90_cyclomatic=8"
1178        );
1179    }
1180
1181    #[test]
1182    fn health_compact_lines_include_styling_findings() {
1183        let root = PathBuf::from("/project");
1184        let report = fallow_output::HealthReport {
1185            styling_findings: vec![fallow_output::StylingFinding {
1186                code: "css-token-drift".to_string(),
1187                sub_kind: "tailwind-arbitrary-value".to_string(),
1188                path: "/project/src/app.css".to_string(),
1189                line: 6,
1190                value: "--color-brand: rgb(240, 90, 41)".to_string(),
1191                effective_severity: fallow_output::StylingFindingSeverity::Warn,
1192                blast_radius: None,
1193                confidence: None,
1194                agent_disposition: None,
1195                nearest_token: None,
1196                fix_hint: None,
1197                actions: Vec::new(),
1198            }],
1199            ..Default::default()
1200        };
1201
1202        let lines = build_health_compact_lines(&report, &root);
1203
1204        assert_eq!(
1205            lines,
1206            vec![
1207                "css-token-drift:src/app.css:6:tailwind-arbitrary-value:severity=warn,value=--color-brand rgb(240 90 41)"
1208            ]
1209        );
1210    }
1211}