Skip to main content

fallow_api/
compact_output.rs

1use std::path::Path;
2
3use fallow_engine::duplicates::{CloneFingerprintSet, DuplicationReport};
4use fallow_output::normalize_uri;
5use fallow_types::results::{AnalysisResults, UnusedExport, UnusedMember};
6
7use crate::ResultGroup;
8
9fn relative_path<'a>(path: &'a Path, root: &Path) -> &'a Path {
10    path.strip_prefix(root).unwrap_or(path)
11}
12
13fn compact_path(path: &Path, root: &Path) -> String {
14    normalize_uri(&relative_path(path, root).display().to_string())
15}
16
17fn compact_circular_dependency_line(
18    cycle: &fallow_types::output_dead_code::CircularDependencyFinding,
19    root: &Path,
20) -> String {
21    let chain: Vec<String> = cycle
22        .cycle
23        .files
24        .iter()
25        .map(|path| compact_path(path, root))
26        .collect();
27    let mut display_chain = chain.clone();
28    if let Some(first) = chain.first() {
29        display_chain.push(first.clone());
30    }
31    let first_file = chain.first().map_or_else(String::new, Clone::clone);
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_else(String::new, Clone::clone);
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    }
314
315    fn push_graph_lines(&mut self) {
316        self.push_structure_lines();
317        self.push_framework_lines();
318        self.push_component_lines();
319        self.push_route_lines();
320        self.push_suppression_lines();
321    }
322
323    fn push_structure_lines(&mut self) {
324        for cycle in &self.results.circular_dependencies {
325            self.lines
326                .push(compact_circular_dependency_line(cycle, self.root));
327        }
328        for cycle in &self.results.re_export_cycles {
329            self.lines
330                .push(compact_re_export_cycle_line(cycle, self.root));
331        }
332        for violation in &self.results.boundary_violations {
333            self.lines
334                .push(compact_boundary_violation_line(violation, self.root));
335        }
336        for violation in &self.results.boundary_coverage_violations {
337            self.lines
338                .push(compact_boundary_coverage_line(violation, self.root));
339        }
340        for violation in &self.results.boundary_call_violations {
341            self.lines
342                .push(compact_boundary_call_line(violation, self.root));
343        }
344        for violation in &self.results.policy_violations {
345            self.lines.push(format!(
346                "policy-violation:{}:{}:{} banned by {}/{}",
347                self.rel(&violation.violation.path),
348                violation.violation.line,
349                violation.violation.matched,
350                violation.violation.pack,
351                violation.violation.rule_id,
352            ));
353        }
354    }
355
356    fn push_framework_lines(&mut self) {
357        for finding in &self.results.invalid_client_exports {
358            self.lines.push(format!(
359                "invalid-client-export:{}:{}:{} (from \"{}\")",
360                self.rel(&finding.export.path),
361                finding.export.line,
362                finding.export.export_name,
363                finding.export.directive,
364            ));
365        }
366        for finding in &self.results.mixed_client_server_barrels {
367            self.lines.push(format!(
368                "mixed-client-server-barrel:{}:{}:{} (server-only \"{}\")",
369                self.rel(&finding.barrel.path),
370                finding.barrel.line,
371                finding.barrel.client_origin,
372                finding.barrel.server_origin,
373            ));
374        }
375        for finding in &self.results.misplaced_directives {
376            self.lines.push(format!(
377                "misplaced-directive:{}:{}:{}",
378                self.rel(&finding.directive_site.path),
379                finding.directive_site.line,
380                finding.directive_site.directive,
381            ));
382        }
383        for finding in &self.results.unprovided_injects {
384            self.lines.push(format!(
385                "unprovided-inject:{}:{}:{}",
386                self.rel(&finding.inject.path),
387                finding.inject.line,
388                finding.inject.key_name,
389            ));
390        }
391    }
392
393    fn push_component_lines(&mut self) {
394        self.push_component_member_lines();
395        self.push_component_framework_lines();
396    }
397
398    /// Push compact lines for unrendered components, props, emits, inputs, and outputs.
399    fn push_component_member_lines(&mut self) {
400        for finding in &self.results.unrendered_components {
401            self.lines.push(format!(
402                "unrendered-component:{}:{}:{}",
403                self.rel(&finding.component.path),
404                finding.component.line,
405                finding.component.component_name,
406            ));
407        }
408        for finding in &self.results.unused_component_props {
409            self.lines.push(format!(
410                "unused-component-prop:{}:{}:{}",
411                self.rel(&finding.prop.path),
412                finding.prop.line,
413                finding.prop.prop_name,
414            ));
415        }
416        for finding in &self.results.unused_component_emits {
417            self.lines.push(format!(
418                "unused-component-emit:{}:{}:{}",
419                self.rel(&finding.emit.path),
420                finding.emit.line,
421                finding.emit.emit_name,
422            ));
423        }
424        for finding in &self.results.unused_component_inputs {
425            self.lines.push(format!(
426                "unused-component-input:{}:{}:{}",
427                self.rel(&finding.input.path),
428                finding.input.line,
429                finding.input.input_name,
430            ));
431        }
432        for finding in &self.results.unused_component_outputs {
433            self.lines.push(format!(
434                "unused-component-output:{}:{}:{}",
435                self.rel(&finding.output.path),
436                finding.output.line,
437                finding.output.output_name,
438            ));
439        }
440    }
441
442    /// Push compact lines for Svelte events, server actions, and load-data keys.
443    fn push_component_framework_lines(&mut self) {
444        for finding in &self.results.unused_svelte_events {
445            self.lines.push(format!(
446                "unused-svelte-event:{}:{}:{}",
447                self.rel(&finding.event.path),
448                finding.event.line,
449                finding.event.event_name,
450            ));
451        }
452        for finding in &self.results.unused_server_actions {
453            self.lines.push(format!(
454                "unused-server-action:{}:{}:{}",
455                self.rel(&finding.action.path),
456                finding.action.line,
457                finding.action.action_name,
458            ));
459        }
460        for finding in &self.results.unused_load_data_keys {
461            self.lines.push(format!(
462                "unused-load-data-key:{}:{}:{}",
463                self.rel(&finding.key.path),
464                finding.key.line,
465                finding.key.key_name,
466            ));
467        }
468    }
469
470    fn push_route_lines(&mut self) {
471        for finding in &self.results.route_collisions {
472            self.lines.push(format!(
473                "route-collision:{}:{} (url {})",
474                self.rel(&finding.collision.path),
475                finding.collision.line,
476                finding.collision.url,
477            ));
478        }
479        for finding in &self.results.dynamic_segment_name_conflicts {
480            self.lines.push(format!(
481                "dynamic-segment-name-conflict:{}:{} ({} at {})",
482                self.rel(&finding.conflict.path),
483                finding.conflict.line,
484                finding.conflict.conflicting_segments.join(" vs "),
485                finding.conflict.position,
486            ));
487        }
488    }
489
490    fn push_suppression_lines(&mut self) {
491        for suppression in &self.results.stale_suppressions {
492            self.lines
493                .push(compact_stale_suppression_line(suppression, self.root));
494        }
495    }
496
497    fn push_workspace_lines(&mut self) {
498        for entry in &self.results.unused_catalog_entries {
499            self.lines.push(format!(
500                "unused-catalog-entry:{}:{}:{}:{}",
501                self.rel(&entry.entry.path),
502                entry.entry.line,
503                entry.entry.catalog_name,
504                entry.entry.entry_name,
505            ));
506        }
507        for group in &self.results.empty_catalog_groups {
508            self.lines.push(format!(
509                "empty-catalog-group:{}:{}:{}",
510                self.rel(&group.group.path),
511                group.group.line,
512                group.group.catalog_name,
513            ));
514        }
515        for finding in &self.results.unresolved_catalog_references {
516            self.lines
517                .push(compact_catalog_reference_line(finding, self.root));
518        }
519        for finding in &self.results.unused_dependency_overrides {
520            self.lines
521                .push(compact_unused_override_line(finding, self.root));
522        }
523        for finding in &self.results.misconfigured_dependency_overrides {
524            self.lines
525                .push(compact_misconfigured_override_line(finding, self.root));
526        }
527    }
528}
529
530/// Build grouped compact output lines, each prefixed with the group key.
531///
532/// Format: `group-key\tissue-tag:details`
533#[must_use]
534pub fn build_grouped_compact_lines(groups: &[ResultGroup], root: &Path) -> Vec<String> {
535    groups
536        .iter()
537        .flat_map(|group| {
538            build_compact_lines(&group.results, root)
539                .into_iter()
540                .map(|line| format!("{}\t{line}", group.key))
541        })
542        .collect()
543}
544
545/// Build compact output lines for health results.
546#[must_use]
547pub fn build_health_compact_lines(
548    report: &fallow_output::HealthReport,
549    root: &Path,
550) -> Vec<String> {
551    let mut lines = Vec::new();
552    push_health_score_compact(&mut lines, report);
553    push_vital_signs_compact(&mut lines, report);
554    push_health_findings_compact(&mut lines, &report.findings, root);
555    push_threshold_overrides_compact(&mut lines, &report.threshold_overrides, root);
556    push_file_scores_compact(&mut lines, &report.file_scores, root);
557    push_coverage_gaps_compact(&mut lines, report, root);
558    push_runtime_sections_compact(&mut lines, report, root);
559    push_hotspots_compact(&mut lines, &report.hotspots, root);
560    push_health_trend_compact(&mut lines, report);
561    push_refactoring_targets_compact(&mut lines, &report.targets, root);
562    lines
563}
564
565fn push_threshold_overrides_compact(
566    lines: &mut Vec<String>,
567    entries: &[fallow_output::ThresholdOverrideState],
568    root: &Path,
569) {
570    for entry in entries {
571        let status = match entry.status {
572            fallow_output::ThresholdOverrideStatus::Active => "active",
573            fallow_output::ThresholdOverrideStatus::Stale => "stale",
574            fallow_output::ThresholdOverrideStatus::NoMatch => "no_match",
575        };
576        let target = entry.path.as_ref().map_or_else(
577            || "no-match".to_string(),
578            |path| {
579                let display = health_compact_path(path, root);
580                entry
581                    .function
582                    .as_ref()
583                    .map_or_else(|| display.clone(), |name| format!("{display}:{name}"))
584            },
585        );
586        let metrics = entry.metrics.map_or(String::new(), |metrics| {
587            let crap = metrics
588                .crap
589                .map_or(String::new(), |value| format!(",crap={value:.1}"));
590            format!(
591                ",cyclomatic={},cognitive={}{}",
592                metrics.cyclomatic, metrics.cognitive, crap
593            )
594        });
595        lines.push(format!(
596            "threshold-override:{}:{}:{}{}",
597            entry.override_index, status, target, metrics
598        ));
599    }
600}
601
602fn push_health_score_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
603    if let Some(ref hs) = report.health_score {
604        lines.push(format!("health-score:{:.1}:{}", hs.score, hs.grade));
605    }
606}
607
608fn push_vital_signs_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
609    if let Some(ref vs) = report.vital_signs {
610        let mut parts = Vec::new();
611        if vs.total_loc > 0 {
612            parts.push(format!("total_loc={}", vs.total_loc));
613        }
614        parts.push(format!("avg_cyclomatic={:.1}", vs.avg_cyclomatic));
615        parts.push(format!("p90_cyclomatic={}", vs.p90_cyclomatic));
616        if let Some(v) = vs.dead_file_pct {
617            parts.push(format!("dead_file_pct={v:.1}"));
618        }
619        if let Some(v) = vs.dead_export_pct {
620            parts.push(format!("dead_export_pct={v:.1}"));
621        }
622        if let Some(v) = vs.maintainability_avg {
623            parts.push(format!("maintainability_avg={v:.1}"));
624        }
625        if let Some(v) = vs.hotspot_count {
626            parts.push(format!("hotspot_count={v}"));
627        }
628        if let Some(v) = vs.circular_dep_count {
629            parts.push(format!("circular_dep_count={v}"));
630        }
631        if let Some(v) = vs.unused_dep_count {
632            parts.push(format!("unused_dep_count={v}"));
633        }
634        lines.push(format!("vital-signs:{}", parts.join(",")));
635    }
636}
637
638fn health_compact_path(path: &Path, root: &Path) -> String {
639    normalize_uri(&relative_path(path, root).display().to_string())
640}
641
642fn push_health_findings_compact(
643    lines: &mut Vec<String>,
644    findings: &[fallow_output::HealthFinding],
645    root: &Path,
646) {
647    for finding in findings {
648        let relative = health_compact_path(&finding.path, root);
649        let severity = match finding.severity {
650            fallow_output::FindingSeverity::Critical => "critical",
651            fallow_output::FindingSeverity::High => "high",
652            fallow_output::FindingSeverity::Moderate => "moderate",
653        };
654        let crap_suffix = match finding.crap {
655            Some(crap) => {
656                let coverage = finding
657                    .coverage_pct
658                    .map(|pct| format!(",coverage_pct={pct:.1}"))
659                    .unwrap_or_default();
660                format!(",crap={crap:.1}{coverage}")
661            }
662            None => String::new(),
663        };
664        lines.push(format!(
665            "high-complexity:{}:{}:{}:cyclomatic={},cognitive={},severity={}{}",
666            relative,
667            finding.line,
668            finding.name,
669            finding.cyclomatic,
670            finding.cognitive,
671            severity,
672            crap_suffix,
673        ));
674    }
675}
676
677fn push_file_scores_compact(
678    lines: &mut Vec<String>,
679    scores: &[fallow_output::FileHealthScore],
680    root: &Path,
681) {
682    for score in scores {
683        let relative = health_compact_path(&score.path, root);
684        lines.push(format!(
685            "file-score:{}:mi={:.1},fan_in={},fan_out={},dead={:.2},density={:.2},crap_max={:.1},crap_above={}",
686            relative,
687            score.maintainability_index,
688            score.fan_in,
689            score.fan_out,
690            score.dead_code_ratio,
691            score.complexity_density,
692            score.crap_max,
693            score.crap_above_threshold,
694        ));
695    }
696}
697
698fn push_coverage_gaps_compact(
699    lines: &mut Vec<String>,
700    report: &fallow_output::HealthReport,
701    root: &Path,
702) {
703    if let Some(ref gaps) = report.coverage_gaps {
704        lines.push(format!(
705            "coverage-gap-summary:runtime_files={},covered_files={},file_coverage_pct={:.1},untested_files={},untested_exports={}",
706            gaps.summary.runtime_files,
707            gaps.summary.covered_files,
708            gaps.summary.file_coverage_pct,
709            gaps.summary.untested_files,
710            gaps.summary.untested_exports,
711        ));
712        for item in &gaps.files {
713            let relative = health_compact_path(&item.file.path, root);
714            lines.push(format!(
715                "untested-file:{}:value_exports={}",
716                relative, item.file.value_export_count,
717            ));
718        }
719        for item in &gaps.exports {
720            let relative = health_compact_path(&item.export.path, root);
721            lines.push(format!(
722                "untested-export:{}:{}:{}",
723                relative, item.export.line, item.export.export_name,
724            ));
725        }
726    }
727}
728
729fn push_runtime_sections_compact(
730    lines: &mut Vec<String>,
731    report: &fallow_output::HealthReport,
732    root: &Path,
733) {
734    if let Some(ref production) = report.runtime_coverage {
735        lines.extend(build_runtime_coverage_compact_lines(production, root));
736    }
737    if let Some(ref intelligence) = report.coverage_intelligence {
738        lines.extend(build_coverage_intelligence_compact_lines(
739            intelligence,
740            root,
741        ));
742    }
743}
744
745fn compact_ownership_suffix(ownership: Option<&fallow_output::OwnershipMetrics>) -> String {
746    ownership.map_or_else(String::new, |o| {
747        let mut parts = vec![
748            format!("bus={}", o.bus_factor),
749            format!("contributors={}", o.contributor_count),
750            format!("top={}", o.top_contributor.identifier),
751            format!("top_share={:.3}", o.top_contributor.share),
752        ];
753        if let Some(owner) = &o.declared_owner {
754            parts.push(format!("owner={owner}"));
755        }
756        if let Some(unowned) = o.unowned {
757            parts.push(format!("unowned={unowned}"));
758        }
759        let state = match o.ownership_state {
760            fallow_output::OwnershipState::Active => "active",
761            fallow_output::OwnershipState::Unowned => "unowned",
762            fallow_output::OwnershipState::DeclaredInactive => "declared_inactive",
763            fallow_output::OwnershipState::Drifting => "drifting",
764        };
765        parts.push(format!("ownership_state={state}"));
766        if o.drift {
767            parts.push("drift=true".to_string());
768        }
769        format!(",{}", parts.join(","))
770    })
771}
772
773fn push_hotspots_compact(
774    lines: &mut Vec<String>,
775    hotspots: &[fallow_output::HotspotFinding],
776    root: &Path,
777) {
778    for entry in hotspots {
779        let relative = health_compact_path(&entry.path, root);
780        let ownership_suffix = compact_ownership_suffix(entry.ownership.as_ref());
781        lines.push(format!(
782            "hotspot:{}:score={:.1},commits={},churn={},density={:.2},fan_in={},trend={}{}",
783            relative,
784            entry.score,
785            entry.commits,
786            entry.lines_added + entry.lines_deleted,
787            entry.complexity_density,
788            entry.fan_in,
789            entry.trend,
790            ownership_suffix,
791        ));
792    }
793}
794
795fn push_health_trend_compact(lines: &mut Vec<String>, report: &fallow_output::HealthReport) {
796    if let Some(ref trend) = report.health_trend {
797        lines.push(format!(
798            "trend:overall:direction={}",
799            trend.overall_direction.label()
800        ));
801        for m in &trend.metrics {
802            lines.push(format!(
803                "trend:{}:previous={:.1},current={:.1},delta={:+.1},direction={}",
804                m.name,
805                m.previous,
806                m.current,
807                m.delta,
808                m.direction.label(),
809            ));
810        }
811    }
812}
813
814fn push_refactoring_targets_compact(
815    lines: &mut Vec<String>,
816    targets: &[fallow_output::RefactoringTargetFinding],
817    root: &Path,
818) {
819    for target in targets {
820        let relative = health_compact_path(&target.path, root);
821        let category = target.category.compact_label();
822        let effort = target.effort.label();
823        let confidence = target.confidence.label();
824        lines.push(format!(
825            "refactoring-target:{}:priority={:.1},efficiency={:.1},category={},effort={},confidence={}:{}",
826            relative,
827            target.priority,
828            target.efficiency,
829            category,
830            effort,
831            confidence,
832            target.recommendation,
833        ));
834    }
835}
836
837fn build_runtime_coverage_compact_lines(
838    production: &fallow_output::RuntimeCoverageReport,
839    root: &Path,
840) -> Vec<String> {
841    let mut lines = vec![format!(
842        "runtime-coverage-summary:functions_tracked={},functions_hit={},functions_unhit={},functions_untracked={},coverage_percent={:.1},trace_count={},period_days={},deployments_seen={}",
843        production.summary.functions_tracked,
844        production.summary.functions_hit,
845        production.summary.functions_unhit,
846        production.summary.functions_untracked,
847        production.summary.coverage_percent,
848        production.summary.trace_count,
849        production.summary.period_days,
850        production.summary.deployments_seen,
851    )];
852    for finding in &production.findings {
853        let relative = normalize_uri(&relative_path(&finding.path, root).display().to_string());
854        let invocations = finding
855            .invocations
856            .map_or_else(|| "null".to_owned(), |hits| hits.to_string());
857        lines.push(format!(
858            "runtime-coverage:{}:{}:{}:id={},verdict={},invocations={},confidence={}",
859            relative,
860            finding.line,
861            finding.function,
862            finding.id,
863            finding.verdict,
864            invocations,
865            finding.confidence,
866        ));
867    }
868    for entry in &production.hot_paths {
869        let relative = normalize_uri(&relative_path(&entry.path, root).display().to_string());
870        lines.push(format!(
871            "production-hot-path:{}:{}:{}:id={},invocations={},percentile={}",
872            relative, entry.line, entry.function, entry.id, entry.invocations, entry.percentile,
873        ));
874    }
875    lines
876}
877
878fn build_coverage_intelligence_compact_lines(
879    intelligence: &fallow_output::CoverageIntelligenceReport,
880    root: &Path,
881) -> Vec<String> {
882    let mut lines = vec![format!(
883        "coverage-intelligence-summary:verdict={},findings={},risky_changes={},high_confidence_deletes={},review_required={},refactor_carefully={},skipped_ambiguous_matches={}",
884        intelligence.verdict,
885        intelligence.summary.findings,
886        intelligence.summary.risky_changes,
887        intelligence.summary.high_confidence_deletes,
888        intelligence.summary.review_required,
889        intelligence.summary.refactor_carefully,
890        intelligence.summary.skipped_ambiguous_matches,
891    )];
892    for finding in &intelligence.findings {
893        let relative = normalize_uri(&relative_path(&finding.path, root).display().to_string());
894        let identity = finding.identity.as_deref().unwrap_or("-");
895        let signals = finding
896            .signals
897            .iter()
898            .map(ToString::to_string)
899            .collect::<Vec<_>>()
900            .join("+");
901        lines.push(format!(
902            "coverage-intelligence:{}:{}:{}:id={},verdict={},recommendation={},confidence={},signals={}",
903            relative,
904            finding.line,
905            identity,
906            finding.id,
907            finding.verdict,
908            finding.recommendation,
909            finding.confidence,
910            signals,
911        ));
912    }
913    lines
914}
915
916/// Build compact output lines for duplication results.
917#[must_use]
918pub fn build_duplication_compact_lines(report: &DuplicationReport, root: &Path) -> Vec<String> {
919    let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
920    let mut lines = Vec::new();
921    for (index, group) in report.clone_groups.iter().enumerate() {
922        let fingerprint = fingerprints.fingerprint_for_group(group);
923        for instance in &group.instances {
924            lines.push(format!(
925                "code-duplication:{}:{}-{}:fingerprint={},group={},tokens={},lines={},instances={}",
926                compact_path(&instance.file, root),
927                instance.start_line,
928                instance.end_line,
929                fingerprint,
930                index + 1,
931                group.token_count,
932                group.line_count,
933                group.instances.len(),
934            ));
935        }
936    }
937    lines
938}
939
940#[cfg(test)]
941mod tests {
942    use std::path::PathBuf;
943
944    use fallow_engine::duplicates::{CloneGroup, CloneInstance, DuplicationStats};
945    use fallow_types::output_dead_code::UnusedFileFinding;
946    use fallow_types::results::{AnalysisResults, UnusedFile};
947
948    use super::*;
949
950    #[test]
951    fn compact_unused_file_format_uses_relative_paths() {
952        let root = PathBuf::from("/project");
953        let mut results = AnalysisResults::default();
954        results
955            .unused_files
956            .push(UnusedFileFinding::with_actions(UnusedFile {
957                path: root.join("src/dead.ts"),
958            }));
959
960        let lines = build_compact_lines(&results, &root);
961
962        assert_eq!(lines, vec!["unused-file:src/dead.ts"]);
963    }
964
965    #[test]
966    fn grouped_compact_prefixes_each_issue_with_group_key() {
967        let root = PathBuf::from("/project");
968        let mut results = AnalysisResults::default();
969        results
970            .unused_files
971            .push(UnusedFileFinding::with_actions(UnusedFile {
972                path: root.join("src/dead.ts"),
973            }));
974        let groups = vec![ResultGroup {
975            key: "team-a".to_owned(),
976            owners: Some(vec!["@team-a".to_owned()]),
977            results,
978        }];
979
980        let lines = build_grouped_compact_lines(&groups, &root);
981
982        assert_eq!(lines, vec!["team-a\tunused-file:src/dead.ts"]);
983    }
984
985    #[test]
986    fn duplication_compact_lines_include_stable_group_context() {
987        let root = PathBuf::from("/project");
988        let report = DuplicationReport {
989            clone_groups: vec![CloneGroup {
990                instances: vec![CloneInstance {
991                    file: root.join("src/a.ts"),
992                    start_line: 2,
993                    end_line: 6,
994                    start_col: 0,
995                    end_col: 10,
996                    fragment: "const duplicated = true;".to_owned(),
997                }],
998                token_count: 12,
999                line_count: 5,
1000            }],
1001            clone_families: Vec::new(),
1002            mirrored_directories: Vec::new(),
1003            stats: DuplicationStats::default(),
1004        };
1005
1006        let lines = build_duplication_compact_lines(&report, &root);
1007
1008        assert_eq!(lines.len(), 1);
1009        assert!(lines[0].starts_with("code-duplication:src/a.ts:2-6:fingerprint="));
1010        assert!(lines[0].contains(",group=1,tokens=12,lines=5,instances=1"));
1011    }
1012
1013    #[test]
1014    fn health_compact_lines_include_score_and_vital_signs() {
1015        let root = PathBuf::from("/project");
1016        let report = fallow_output::HealthReport {
1017            health_score: Some(fallow_output::HealthScore {
1018                formula_version: 1,
1019                score: 91.2,
1020                grade: "A",
1021                penalties: fallow_output::HealthScorePenalties {
1022                    dead_files: None,
1023                    dead_exports: None,
1024                    complexity: 0.0,
1025                    p90_complexity: 0.0,
1026                    maintainability: None,
1027                    hotspots: None,
1028                    unused_deps: None,
1029                    circular_deps: None,
1030                    unit_size: None,
1031                    coupling: None,
1032                    duplication: None,
1033                    prop_drilling: None,
1034                },
1035            }),
1036            vital_signs: Some(fallow_output::VitalSigns {
1037                total_loc: 120,
1038                avg_cyclomatic: 3.4,
1039                p90_cyclomatic: 8,
1040                ..Default::default()
1041            }),
1042            ..Default::default()
1043        };
1044
1045        let lines = build_health_compact_lines(&report, &root);
1046
1047        assert_eq!(lines[0], "health-score:91.2:A");
1048        assert_eq!(
1049            lines[1],
1050            "vital-signs:total_loc=120,avg_cyclomatic=3.4,p90_cyclomatic=8"
1051        );
1052    }
1053}