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