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::NoMatch => "no_match",
608        };
609        let target = entry.path.as_ref().map_or_else(
610            || "no-match".to_string(),
611            |path| {
612                let display = health_compact_path(path, root);
613                entry
614                    .function
615                    .as_ref()
616                    .map_or_else(|| display.clone(), |name| format!("{display}:{name}"))
617            },
618        );
619        let metrics = entry.metrics.map_or(String::new(), |metrics| {
620            let crap = metrics
621                .crap
622                .map_or(String::new(), |value| format!(",crap={value:.1}"));
623            format!(
624                ",cyclomatic={},cognitive={}{}",
625                metrics.cyclomatic, metrics.cognitive, crap
626            )
627        });
628        lines.push(format!(
629            "threshold-override:{}:{}:{}{}",
630            entry.override_index, status, target, metrics
631        ));
632    }
633}
634
635fn push_health_score_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
636    if let Some(ref hs) = report.health_score {
637        lines.push(format!("health-score:{:.1}:{}", hs.score, hs.grade));
638    }
639}
640
641fn push_vital_signs_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
642    if let Some(ref vs) = report.vital_signs {
643        let mut parts = Vec::new();
644        if vs.total_loc > 0 {
645            parts.push(format!("total_loc={}", vs.total_loc));
646        }
647        parts.push(format!("avg_cyclomatic={:.1}", vs.avg_cyclomatic));
648        parts.push(format!("p90_cyclomatic={}", vs.p90_cyclomatic));
649        if let Some(v) = vs.dead_file_pct {
650            parts.push(format!("dead_file_pct={v:.1}"));
651        }
652        if let Some(v) = vs.dead_export_pct {
653            parts.push(format!("dead_export_pct={v:.1}"));
654        }
655        if let Some(v) = vs.maintainability_avg {
656            parts.push(format!("maintainability_avg={v:.1}"));
657        }
658        if let Some(v) = vs.hotspot_count {
659            parts.push(format!("hotspot_count={v}"));
660        }
661        if let Some(v) = vs.circular_dep_count {
662            parts.push(format!("circular_dep_count={v}"));
663        }
664        if let Some(v) = vs.unused_dep_count {
665            parts.push(format!("unused_dep_count={v}"));
666        }
667        lines.push(format!("vital-signs:{}", parts.join(",")));
668    }
669}
670
671fn health_compact_path(path: &Path, root: &Path) -> String {
672    normalize_uri(&relative_path(path, root).display().to_string())
673}
674
675fn push_health_findings_compact(
676    lines: &mut Vec<String>,
677    findings: &[fallow_output::HealthFinding],
678    root: &Path,
679) {
680    for finding in findings {
681        let relative = health_compact_path(&finding.path, root);
682        let severity = match finding.severity {
683            fallow_output::FindingSeverity::Critical => "critical",
684            fallow_output::FindingSeverity::High => "high",
685            fallow_output::FindingSeverity::Moderate => "moderate",
686        };
687        let crap_suffix = match finding.crap {
688            Some(crap) => {
689                let coverage = finding
690                    .coverage_pct
691                    .map(|pct| format!(",coverage_pct={pct:.1}"))
692                    .unwrap_or_default();
693                format!(",crap={crap:.1}{coverage}")
694            }
695            None => String::new(),
696        };
697        lines.push(format!(
698            "high-complexity:{}:{}:{}:cyclomatic={},cognitive={},severity={}{}",
699            relative,
700            finding.line,
701            finding.name,
702            finding.cyclomatic,
703            finding.cognitive,
704            severity,
705            crap_suffix,
706        ));
707    }
708}
709
710fn push_file_scores_compact(
711    lines: &mut Vec<String>,
712    scores: &[fallow_output::FileHealthScore],
713    root: &Path,
714) {
715    for score in scores {
716        let relative = health_compact_path(&score.path, root);
717        lines.push(format!(
718            "file-score:{}:mi={:.1},fan_in={},fan_out={},dead={:.2},density={:.2},crap_max={:.1},crap_above={}",
719            relative,
720            score.maintainability_index,
721            score.fan_in,
722            score.fan_out,
723            score.dead_code_ratio,
724            score.complexity_density,
725            score.crap_max,
726            score.crap_above_threshold,
727        ));
728    }
729}
730
731fn push_coverage_gaps_compact(
732    lines: &mut Vec<String>,
733    report: &fallow_output::HealthReport,
734    root: &Path,
735) {
736    if let Some(ref gaps) = report.coverage_gaps {
737        lines.push(format!(
738            "coverage-gap-summary:runtime_files={},covered_files={},file_coverage_pct={:.1},untested_files={},untested_exports={}",
739            gaps.summary.runtime_files,
740            gaps.summary.covered_files,
741            gaps.summary.file_coverage_pct,
742            gaps.summary.untested_files,
743            gaps.summary.untested_exports,
744        ));
745        for item in &gaps.files {
746            let relative = health_compact_path(&item.file.path, root);
747            lines.push(format!(
748                "untested-file:{}:value_exports={}",
749                relative, item.file.value_export_count,
750            ));
751        }
752        for item in &gaps.exports {
753            let relative = health_compact_path(&item.export.path, root);
754            lines.push(format!(
755                "untested-export:{}:{}:{}",
756                relative, item.export.line, item.export.export_name,
757            ));
758        }
759    }
760}
761
762fn push_runtime_sections_compact(
763    lines: &mut Vec<String>,
764    report: &fallow_output::HealthReport,
765    root: &Path,
766) {
767    if let Some(ref production) = report.runtime_coverage {
768        lines.extend(build_runtime_coverage_compact_lines(production, root));
769    }
770    if let Some(ref intelligence) = report.coverage_intelligence {
771        lines.extend(build_coverage_intelligence_compact_lines(
772            intelligence,
773            root,
774        ));
775    }
776}
777
778fn compact_ownership_suffix(ownership: Option<&fallow_output::OwnershipMetrics>) -> String {
779    ownership.map_or_else(String::new, |o| {
780        let mut parts = vec![
781            format!("bus={}", o.bus_factor),
782            format!("contributors={}", o.contributor_count),
783            format!("top={}", o.top_contributor.identifier),
784            format!("top_share={:.3}", o.top_contributor.share),
785        ];
786        if let Some(owner) = &o.declared_owner {
787            parts.push(format!("owner={owner}"));
788        }
789        if let Some(unowned) = o.unowned {
790            parts.push(format!("unowned={unowned}"));
791        }
792        let state = match o.ownership_state {
793            fallow_output::OwnershipState::Active => "active",
794            fallow_output::OwnershipState::Unowned => "unowned",
795            fallow_output::OwnershipState::DeclaredInactive => "declared_inactive",
796            fallow_output::OwnershipState::Drifting => "drifting",
797        };
798        parts.push(format!("ownership_state={state}"));
799        if o.drift {
800            parts.push("drift=true".to_string());
801        }
802        format!(",{}", parts.join(","))
803    })
804}
805
806fn push_hotspots_compact(
807    lines: &mut Vec<String>,
808    hotspots: &[fallow_output::HotspotFinding],
809    root: &Path,
810) {
811    for entry in hotspots {
812        let relative = health_compact_path(&entry.path, root);
813        let ownership_suffix = compact_ownership_suffix(entry.ownership.as_ref());
814        lines.push(format!(
815            "hotspot:{}:score={:.1},commits={},churn={},density={:.2},fan_in={},trend={}{}",
816            relative,
817            entry.score,
818            entry.commits,
819            entry.lines_added + entry.lines_deleted,
820            entry.complexity_density,
821            entry.fan_in,
822            entry.trend,
823            ownership_suffix,
824        ));
825    }
826}
827
828fn push_health_trend_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
829    if let Some(ref trend) = report.health_trend {
830        lines.push(format!(
831            "trend:overall:direction={}",
832            trend.overall_direction.label()
833        ));
834        for m in &trend.metrics {
835            lines.push(format!(
836                "trend:{}:previous={:.1},current={:.1},delta={:+.1},direction={}",
837                m.name,
838                m.previous,
839                m.current,
840                m.delta,
841                m.direction.label(),
842            ));
843        }
844    }
845}
846
847fn push_refactoring_targets_compact(
848    lines: &mut Vec<String>,
849    targets: &[fallow_output::RefactoringTargetFinding],
850    root: &Path,
851) {
852    for target in targets {
853        let relative = health_compact_path(&target.path, root);
854        let category = target.category.compact_label();
855        let effort = target.effort.label();
856        let confidence = target.confidence.label();
857        lines.push(format!(
858            "refactoring-target:{}:priority={:.1},efficiency={:.1},category={},effort={},confidence={}:{}",
859            relative,
860            target.priority,
861            target.efficiency,
862            category,
863            effort,
864            confidence,
865            target.recommendation,
866        ));
867    }
868}
869
870fn build_runtime_coverage_compact_lines(
871    production: &fallow_output::RuntimeCoverageReport,
872    root: &Path,
873) -> Vec<String> {
874    let mut lines = vec![format!(
875        "runtime-coverage-summary:functions_tracked={},functions_hit={},functions_unhit={},functions_untracked={},coverage_percent={:.1},trace_count={},period_days={},deployments_seen={}",
876        production.summary.functions_tracked,
877        production.summary.functions_hit,
878        production.summary.functions_unhit,
879        production.summary.functions_untracked,
880        production.summary.coverage_percent,
881        production.summary.trace_count,
882        production.summary.period_days,
883        production.summary.deployments_seen,
884    )];
885    for finding in &production.findings {
886        let relative = normalize_uri(&relative_path(&finding.path, root).display().to_string());
887        let invocations = finding
888            .invocations
889            .map_or_else(|| "null".to_owned(), |hits| hits.to_string());
890        lines.push(format!(
891            "runtime-coverage:{}:{}:{}:id={},verdict={},invocations={},confidence={}",
892            relative,
893            finding.line,
894            finding.function,
895            finding.id,
896            finding.verdict,
897            invocations,
898            finding.confidence,
899        ));
900    }
901    for entry in &production.hot_paths {
902        let relative = normalize_uri(&relative_path(&entry.path, root).display().to_string());
903        lines.push(format!(
904            "production-hot-path:{}:{}:{}:id={},invocations={},percentile={}",
905            relative, entry.line, entry.function, entry.id, entry.invocations, entry.percentile,
906        ));
907    }
908    lines
909}
910
911fn build_coverage_intelligence_compact_lines(
912    intelligence: &fallow_output::CoverageIntelligenceReport,
913    root: &Path,
914) -> Vec<String> {
915    let mut lines = vec![format!(
916        "coverage-intelligence-summary:verdict={},findings={},risky_changes={},high_confidence_deletes={},review_required={},refactor_carefully={},skipped_ambiguous_matches={}",
917        intelligence.verdict,
918        intelligence.summary.findings,
919        intelligence.summary.risky_changes,
920        intelligence.summary.high_confidence_deletes,
921        intelligence.summary.review_required,
922        intelligence.summary.refactor_carefully,
923        intelligence.summary.skipped_ambiguous_matches,
924    )];
925    for finding in &intelligence.findings {
926        let relative = normalize_uri(&relative_path(&finding.path, root).display().to_string());
927        let identity = finding.identity.as_deref().unwrap_or("-");
928        let signals = finding
929            .signals
930            .iter()
931            .map(ToString::to_string)
932            .collect::<Vec<_>>()
933            .join("+");
934        lines.push(format!(
935            "coverage-intelligence:{}:{}:{}:id={},verdict={},recommendation={},confidence={},signals={}",
936            relative,
937            finding.line,
938            identity,
939            finding.id,
940            finding.verdict,
941            finding.recommendation,
942            finding.confidence,
943            signals,
944        ));
945    }
946    lines
947}
948
949/// Build compact output lines for duplication results.
950#[must_use]
951pub fn build_duplication_compact_lines(report: &DuplicationReport, root: &Path) -> Vec<String> {
952    let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
953    let mut lines = Vec::new();
954    for (index, group) in report.clone_groups.iter().enumerate() {
955        let fingerprint = fingerprints.fingerprint_for_group(group);
956        for instance in &group.instances {
957            lines.push(format!(
958                "code-duplication:{}:{}-{}:fingerprint={},group={},tokens={},lines={},instances={}",
959                compact_path(&instance.file, root),
960                instance.start_line,
961                instance.end_line,
962                fingerprint,
963                index + 1,
964                group.token_count,
965                group.line_count,
966                group.instances.len(),
967            ));
968        }
969    }
970    lines
971}
972
973#[cfg(test)]
974mod tests {
975    use std::path::PathBuf;
976
977    use fallow_types::duplicates::{CloneGroup, CloneInstance, DuplicationStats};
978    use fallow_types::output_dead_code::UnusedFileFinding;
979    use fallow_types::results::{AnalysisResults, UnusedFile};
980
981    use super::*;
982
983    #[test]
984    fn compact_unused_file_format_uses_relative_paths() {
985        let root = PathBuf::from("/project");
986        let mut results = AnalysisResults::default();
987        results
988            .unused_files
989            .push(UnusedFileFinding::with_actions(UnusedFile {
990                path: root.join("src/dead.ts"),
991            }));
992
993        let lines = build_compact_lines(&results, &root);
994
995        assert_eq!(lines, vec!["unused-file:src/dead.ts"]);
996    }
997
998    #[test]
999    fn grouped_compact_prefixes_each_issue_with_group_key() {
1000        let root = PathBuf::from("/project");
1001        let mut results = AnalysisResults::default();
1002        results
1003            .unused_files
1004            .push(UnusedFileFinding::with_actions(UnusedFile {
1005                path: root.join("src/dead.ts"),
1006            }));
1007        let groups = vec![ResultGroup {
1008            key: "team-a".to_owned(),
1009            owners: Some(vec!["@team-a".to_owned()]),
1010            results,
1011        }];
1012
1013        let lines = build_grouped_compact_lines(&groups, &root);
1014
1015        assert_eq!(lines, vec!["team-a\tunused-file:src/dead.ts"]);
1016    }
1017
1018    #[test]
1019    fn duplication_compact_lines_include_stable_group_context() {
1020        let root = PathBuf::from("/project");
1021        let report = DuplicationReport {
1022            clone_groups: vec![CloneGroup {
1023                instances: vec![CloneInstance {
1024                    file: root.join("src/a.ts"),
1025                    start_line: 2,
1026                    end_line: 6,
1027                    start_col: 0,
1028                    end_col: 10,
1029                    fragment: "const duplicated = true;".to_owned(),
1030                }],
1031                token_count: 12,
1032                line_count: 5,
1033            }],
1034            clone_families: Vec::new(),
1035            mirrored_directories: Vec::new(),
1036            stats: DuplicationStats::default(),
1037        };
1038
1039        let lines = build_duplication_compact_lines(&report, &root);
1040
1041        assert_eq!(lines.len(), 1);
1042        assert!(lines[0].starts_with("code-duplication:src/a.ts:2-6:fingerprint="));
1043        assert!(lines[0].contains(",group=1,tokens=12,lines=5,instances=1"));
1044    }
1045
1046    #[test]
1047    fn health_compact_lines_include_score_and_vital_signs() {
1048        let root = PathBuf::from("/project");
1049        let report = fallow_output::HealthReport {
1050            health_score: Some(fallow_output::HealthScore {
1051                formula_version: 1,
1052                score: 91.2,
1053                grade: "A",
1054                penalties: fallow_output::HealthScorePenalties {
1055                    dead_files: None,
1056                    dead_exports: None,
1057                    complexity: 0.0,
1058                    p90_complexity: 0.0,
1059                    maintainability: None,
1060                    hotspots: None,
1061                    unused_deps: None,
1062                    circular_deps: None,
1063                    unit_size: None,
1064                    coupling: None,
1065                    duplication: None,
1066                    prop_drilling: None,
1067                },
1068            }),
1069            vital_signs: Some(fallow_output::VitalSigns {
1070                total_loc: 120,
1071                avg_cyclomatic: 3.4,
1072                p90_cyclomatic: 8,
1073                ..Default::default()
1074            }),
1075            ..Default::default()
1076        };
1077
1078        let lines = build_health_compact_lines(&report, &root);
1079
1080        assert_eq!(lines[0], "health-score:91.2:A");
1081        assert_eq!(
1082            lines[1],
1083            "vital-signs:total_loc=120,avg_cyclomatic=3.4,p90_cyclomatic=8"
1084        );
1085    }
1086
1087    #[test]
1088    fn health_compact_lines_include_styling_findings() {
1089        let root = PathBuf::from("/project");
1090        let report = fallow_output::HealthReport {
1091            styling_findings: vec![fallow_output::StylingFinding {
1092                code: "css-token-drift".to_string(),
1093                sub_kind: "tailwind-arbitrary-value".to_string(),
1094                path: "/project/src/app.css".to_string(),
1095                line: 6,
1096                value: "--color-brand: rgb(240, 90, 41)".to_string(),
1097                effective_severity: fallow_output::StylingFindingSeverity::Warn,
1098                blast_radius: None,
1099                confidence: None,
1100                agent_disposition: None,
1101                nearest_token: None,
1102                fix_hint: None,
1103                actions: Vec::new(),
1104            }],
1105            ..Default::default()
1106        };
1107
1108        let lines = build_health_compact_lines(&report, &root);
1109
1110        assert_eq!(
1111            lines,
1112            vec![
1113                "css-token-drift:src/app.css:6:tailwind-arbitrary-value:severity=warn,value=--color-brand rgb(240 90 41)"
1114            ]
1115        );
1116    }
1117}