Skip to main content

fallow_cli/report/
compact.rs

1use crate::report::sink::outln;
2use std::path::Path;
3
4use fallow_core::duplicates::DuplicationReport;
5use fallow_core::results::{AnalysisResults, UnusedExport, UnusedMember};
6
7use super::grouping::ResultGroup;
8use super::{normalize_uri, relative_path};
9
10pub(super) fn print_compact(results: &AnalysisResults, root: &Path) {
11    for line in build_compact_lines(results, root) {
12        outln!("{line}");
13    }
14}
15
16/// Build compact output lines for analysis results.
17/// Each issue is represented as a single `prefix:details` line.
18#[expect(
19    clippy::too_many_lines,
20    reason = "One uniform loop per issue type; the line count grows linearly with new issue types and the structure is clearer than extracting per-loop helpers."
21)]
22pub fn build_compact_lines(results: &AnalysisResults, root: &Path) -> Vec<String> {
23    let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
24
25    let compact_export = |export: &UnusedExport, kind: &str, re_kind: &str| -> String {
26        let tag = if export.is_re_export { re_kind } else { kind };
27        format!(
28            "{}:{}:{}:{}",
29            tag,
30            rel(&export.path),
31            export.line,
32            export.export_name
33        )
34    };
35
36    let compact_member = |member: &UnusedMember, kind: &str| -> String {
37        format!(
38            "{}:{}:{}:{}.{}",
39            kind,
40            rel(&member.path),
41            member.line,
42            member.parent_name,
43            member.member_name
44        )
45    };
46
47    let mut lines = Vec::new();
48
49    for file in &results.unused_files {
50        lines.push(format!("unused-file:{}", rel(&file.file.path)));
51    }
52    for export in &results.unused_exports {
53        lines.push(compact_export(
54            &export.export,
55            "unused-export",
56            "unused-re-export",
57        ));
58    }
59    for export in &results.unused_types {
60        lines.push(compact_export(
61            &export.export,
62            "unused-type",
63            "unused-re-export-type",
64        ));
65    }
66    for leak in &results.private_type_leaks {
67        lines.push(format!(
68            "private-type-leak:{}:{}:{}->{}",
69            rel(&leak.leak.path),
70            leak.leak.line,
71            leak.leak.export_name,
72            leak.leak.type_name
73        ));
74    }
75    for dep in &results.unused_dependencies {
76        lines.push(format!("unused-dep:{}", dep.dep.package_name));
77    }
78    for dep in &results.unused_dev_dependencies {
79        lines.push(format!("unused-devdep:{}", dep.dep.package_name));
80    }
81    for dep in &results.unused_optional_dependencies {
82        lines.push(format!("unused-optionaldep:{}", dep.dep.package_name));
83    }
84    for member in &results.unused_enum_members {
85        lines.push(compact_member(&member.member, "unused-enum-member"));
86    }
87    for member in &results.unused_class_members {
88        lines.push(compact_member(&member.member, "unused-class-member"));
89    }
90    for import in &results.unresolved_imports {
91        lines.push(format!(
92            "unresolved-import:{}:{}:{}",
93            rel(&import.import.path),
94            import.import.line,
95            import.import.specifier
96        ));
97    }
98    for dep in &results.unlisted_dependencies {
99        lines.push(format!("unlisted-dep:{}", dep.dep.package_name));
100    }
101    for dup in &results.duplicate_exports {
102        lines.push(format!("duplicate-export:{}", dup.export.export_name));
103    }
104    for dep in &results.type_only_dependencies {
105        lines.push(format!("type-only-dep:{}", dep.dep.package_name));
106    }
107    for dep in &results.test_only_dependencies {
108        lines.push(format!("test-only-dep:{}", dep.dep.package_name));
109    }
110    for cycle in &results.circular_dependencies {
111        let chain: Vec<String> = cycle.cycle.files.iter().map(|p| rel(p)).collect();
112        let mut display_chain = chain.clone();
113        if let Some(first) = chain.first() {
114            display_chain.push(first.clone());
115        }
116        let first_file = chain.first().map_or_else(String::new, Clone::clone);
117        let cross_pkg_tag = if cycle.cycle.is_cross_package {
118            " (cross-package)"
119        } else {
120            ""
121        };
122        lines.push(format!(
123            "circular-dependency:{}:{}:{}{}",
124            first_file,
125            cycle.cycle.line,
126            display_chain.join(" \u{2192} "),
127            cross_pkg_tag
128        ));
129    }
130    for cycle in &results.re_export_cycles {
131        let chain: Vec<String> = cycle.cycle.files.iter().map(|p| rel(p)).collect();
132        let first_file = chain.first().map_or_else(String::new, Clone::clone);
133        let kind_tag = match cycle.cycle.kind {
134            fallow_core::results::ReExportCycleKind::SelfLoop => " (self-loop)",
135            fallow_core::results::ReExportCycleKind::MultiNode => "",
136        };
137        lines.push(format!(
138            "re-export-cycle:{}:{}{}",
139            first_file,
140            chain.join(" <-> "),
141            kind_tag
142        ));
143    }
144    for v in &results.boundary_violations {
145        lines.push(format!(
146            "boundary-violation:{}:{}:{} -> {} ({} -> {})",
147            rel(&v.violation.from_path),
148            v.violation.line,
149            rel(&v.violation.from_path),
150            rel(&v.violation.to_path),
151            v.violation.from_zone,
152            v.violation.to_zone,
153        ));
154    }
155    for v in &results.boundary_coverage_violations {
156        lines.push(format!(
157            "boundary-coverage:{}:{}:no matching boundary zone",
158            rel(&v.violation.path),
159            v.violation.line,
160        ));
161    }
162    for v in &results.boundary_call_violations {
163        lines.push(format!(
164            "boundary-call:{}:{}:{} forbidden in zone {} (pattern {})",
165            rel(&v.violation.path),
166            v.violation.line,
167            v.violation.callee,
168            v.violation.zone,
169            v.violation.pattern,
170        ));
171    }
172    for v in &results.policy_violations {
173        lines.push(format!(
174            "policy-violation:{}:{}:{} banned by {}/{}",
175            rel(&v.violation.path),
176            v.violation.line,
177            v.violation.matched,
178            v.violation.pack,
179            v.violation.rule_id,
180        ));
181    }
182    for s in &results.stale_suppressions {
183        lines.push(format!(
184            "stale-suppression:{}:{}:{}",
185            rel(&s.path),
186            s.line,
187            s.display_message(),
188        ));
189    }
190    for entry in &results.unused_catalog_entries {
191        lines.push(format!(
192            "unused-catalog-entry:{}:{}:{}:{}",
193            rel(&entry.entry.path),
194            entry.entry.line,
195            entry.entry.catalog_name,
196            entry.entry.entry_name,
197        ));
198    }
199    for group in &results.empty_catalog_groups {
200        lines.push(format!(
201            "empty-catalog-group:{}:{}:{}",
202            rel(&group.group.path),
203            group.group.line,
204            group.group.catalog_name,
205        ));
206    }
207    for finding in &results.unresolved_catalog_references {
208        lines.push(format!(
209            "unresolved-catalog-reference:{}:{}:{}:{}",
210            rel(&finding.reference.path),
211            finding.reference.line,
212            finding.reference.catalog_name,
213            finding.reference.entry_name,
214        ));
215    }
216    for finding in &results.unused_dependency_overrides {
217        lines.push(format!(
218            "unused-dependency-override:{}:{}:{}:{}",
219            rel(&finding.entry.path),
220            finding.entry.line,
221            finding.entry.source.as_label(),
222            finding.entry.raw_key,
223        ));
224    }
225    for finding in &results.misconfigured_dependency_overrides {
226        lines.push(format!(
227            "misconfigured-dependency-override:{}:{}:{}:{}",
228            rel(&finding.entry.path),
229            finding.entry.line,
230            finding.entry.source.as_label(),
231            finding.entry.raw_key,
232        ));
233    }
234
235    lines
236}
237
238/// Print grouped compact output: each line is prefixed with the group key.
239///
240/// Format: `group-key\tissue-tag:details`
241pub(super) fn print_grouped_compact(groups: &[ResultGroup], root: &Path) {
242    for group in groups {
243        for line in build_compact_lines(&group.results, root) {
244            outln!("{}\t{line}", group.key);
245        }
246    }
247}
248
249#[expect(
250    clippy::too_many_lines,
251    reason = "health compact formatter stitches many optional sections into one stream"
252)]
253pub(super) fn print_health_compact(report: &crate::health_types::HealthReport, root: &Path) {
254    if let Some(ref hs) = report.health_score {
255        outln!("health-score:{:.1}:{}", hs.score, hs.grade);
256    }
257    if let Some(ref vs) = report.vital_signs {
258        let mut parts = Vec::new();
259        if vs.total_loc > 0 {
260            parts.push(format!("total_loc={}", vs.total_loc));
261        }
262        parts.push(format!("avg_cyclomatic={:.1}", vs.avg_cyclomatic));
263        parts.push(format!("p90_cyclomatic={}", vs.p90_cyclomatic));
264        if let Some(v) = vs.dead_file_pct {
265            parts.push(format!("dead_file_pct={v:.1}"));
266        }
267        if let Some(v) = vs.dead_export_pct {
268            parts.push(format!("dead_export_pct={v:.1}"));
269        }
270        if let Some(v) = vs.maintainability_avg {
271            parts.push(format!("maintainability_avg={v:.1}"));
272        }
273        if let Some(v) = vs.hotspot_count {
274            parts.push(format!("hotspot_count={v}"));
275        }
276        if let Some(v) = vs.circular_dep_count {
277            parts.push(format!("circular_dep_count={v}"));
278        }
279        if let Some(v) = vs.unused_dep_count {
280            parts.push(format!("unused_dep_count={v}"));
281        }
282        outln!("vital-signs:{}", parts.join(","));
283    }
284    for finding in &report.findings {
285        let relative = normalize_uri(&relative_path(&finding.path, root).display().to_string());
286        let severity = match finding.severity {
287            crate::health_types::FindingSeverity::Critical => "critical",
288            crate::health_types::FindingSeverity::High => "high",
289            crate::health_types::FindingSeverity::Moderate => "moderate",
290        };
291        let crap_suffix = match finding.crap {
292            Some(crap) => {
293                let coverage = finding
294                    .coverage_pct
295                    .map(|pct| format!(",coverage_pct={pct:.1}"))
296                    .unwrap_or_default();
297                format!(",crap={crap:.1}{coverage}")
298            }
299            None => String::new(),
300        };
301        outln!(
302            "high-complexity:{}:{}:{}:cyclomatic={},cognitive={},severity={}{}",
303            relative,
304            finding.line,
305            finding.name,
306            finding.cyclomatic,
307            finding.cognitive,
308            severity,
309            crap_suffix,
310        );
311    }
312    for score in &report.file_scores {
313        let relative = normalize_uri(&relative_path(&score.path, root).display().to_string());
314        outln!(
315            "file-score:{}:mi={:.1},fan_in={},fan_out={},dead={:.2},density={:.2},crap_max={:.1},crap_above={}",
316            relative,
317            score.maintainability_index,
318            score.fan_in,
319            score.fan_out,
320            score.dead_code_ratio,
321            score.complexity_density,
322            score.crap_max,
323            score.crap_above_threshold,
324        );
325    }
326    if let Some(ref gaps) = report.coverage_gaps {
327        outln!(
328            "coverage-gap-summary:runtime_files={},covered_files={},file_coverage_pct={:.1},untested_files={},untested_exports={}",
329            gaps.summary.runtime_files,
330            gaps.summary.covered_files,
331            gaps.summary.file_coverage_pct,
332            gaps.summary.untested_files,
333            gaps.summary.untested_exports,
334        );
335        for item in &gaps.files {
336            let relative =
337                normalize_uri(&relative_path(&item.file.path, root).display().to_string());
338            outln!(
339                "untested-file:{}:value_exports={}",
340                relative,
341                item.file.value_export_count,
342            );
343        }
344        for item in &gaps.exports {
345            let relative =
346                normalize_uri(&relative_path(&item.export.path, root).display().to_string());
347            outln!(
348                "untested-export:{}:{}:{}",
349                relative,
350                item.export.line,
351                item.export.export_name,
352            );
353        }
354    }
355    if let Some(ref production) = report.runtime_coverage {
356        for line in build_runtime_coverage_compact_lines(production, root) {
357            outln!("{line}");
358        }
359    }
360    if let Some(ref intelligence) = report.coverage_intelligence {
361        for line in build_coverage_intelligence_compact_lines(intelligence, root) {
362            outln!("{line}");
363        }
364    }
365    for entry in &report.hotspots {
366        let relative = normalize_uri(&relative_path(&entry.path, root).display().to_string());
367        let ownership_suffix = entry
368            .ownership
369            .as_ref()
370            .map(|o| {
371                let mut parts = vec![
372                    format!("bus={}", o.bus_factor),
373                    format!("contributors={}", o.contributor_count),
374                    format!("top={}", o.top_contributor.identifier),
375                    format!("top_share={:.3}", o.top_contributor.share),
376                ];
377                if let Some(owner) = &o.declared_owner {
378                    parts.push(format!("owner={owner}"));
379                }
380                if let Some(unowned) = o.unowned {
381                    parts.push(format!("unowned={unowned}"));
382                }
383                let state = match o.ownership_state {
384                    crate::health_types::OwnershipState::Active => "active",
385                    crate::health_types::OwnershipState::Unowned => "unowned",
386                    crate::health_types::OwnershipState::DeclaredInactive => "declared_inactive",
387                    crate::health_types::OwnershipState::Drifting => "drifting",
388                };
389                parts.push(format!("ownership_state={state}"));
390                if o.drift {
391                    parts.push("drift=true".to_string());
392                }
393                format!(",{}", parts.join(","))
394            })
395            .unwrap_or_default();
396        outln!(
397            "hotspot:{}:score={:.1},commits={},churn={},density={:.2},fan_in={},trend={}{}",
398            relative,
399            entry.score,
400            entry.commits,
401            entry.lines_added + entry.lines_deleted,
402            entry.complexity_density,
403            entry.fan_in,
404            entry.trend,
405            ownership_suffix,
406        );
407    }
408    if let Some(ref trend) = report.health_trend {
409        outln!(
410            "trend:overall:direction={}",
411            trend.overall_direction.label()
412        );
413        for m in &trend.metrics {
414            outln!(
415                "trend:{}:previous={:.1},current={:.1},delta={:+.1},direction={}",
416                m.name,
417                m.previous,
418                m.current,
419                m.delta,
420                m.direction.label(),
421            );
422        }
423    }
424    for target in &report.targets {
425        let relative = normalize_uri(&relative_path(&target.path, root).display().to_string());
426        let category = target.category.compact_label();
427        let effort = target.effort.label();
428        let confidence = target.confidence.label();
429        outln!(
430            "refactoring-target:{}:priority={:.1},efficiency={:.1},category={},effort={},confidence={}:{}",
431            relative,
432            target.priority,
433            target.efficiency,
434            category,
435            effort,
436            confidence,
437            target.recommendation,
438        );
439    }
440}
441
442fn build_runtime_coverage_compact_lines(
443    production: &crate::health_types::RuntimeCoverageReport,
444    root: &Path,
445) -> Vec<String> {
446    let mut lines = vec![format!(
447        "runtime-coverage-summary:functions_tracked={},functions_hit={},functions_unhit={},functions_untracked={},coverage_percent={:.1},trace_count={},period_days={},deployments_seen={}",
448        production.summary.functions_tracked,
449        production.summary.functions_hit,
450        production.summary.functions_unhit,
451        production.summary.functions_untracked,
452        production.summary.coverage_percent,
453        production.summary.trace_count,
454        production.summary.period_days,
455        production.summary.deployments_seen,
456    )];
457    for finding in &production.findings {
458        let relative = normalize_uri(&relative_path(&finding.path, root).display().to_string());
459        let invocations = finding
460            .invocations
461            .map_or_else(|| "null".to_owned(), |hits| hits.to_string());
462        lines.push(format!(
463            "runtime-coverage:{}:{}:{}:id={},verdict={},invocations={},confidence={}",
464            relative,
465            finding.line,
466            finding.function,
467            finding.id,
468            finding.verdict,
469            invocations,
470            finding.confidence,
471        ));
472    }
473    for entry in &production.hot_paths {
474        let relative = normalize_uri(&relative_path(&entry.path, root).display().to_string());
475        lines.push(format!(
476            "production-hot-path:{}:{}:{}:id={},invocations={},percentile={}",
477            relative, entry.line, entry.function, entry.id, entry.invocations, entry.percentile,
478        ));
479    }
480    lines
481}
482
483fn build_coverage_intelligence_compact_lines(
484    intelligence: &crate::health_types::CoverageIntelligenceReport,
485    root: &Path,
486) -> Vec<String> {
487    let mut lines = vec![format!(
488        "coverage-intelligence-summary:verdict={},findings={},risky_changes={},high_confidence_deletes={},review_required={},refactor_carefully={},skipped_ambiguous_matches={}",
489        intelligence.verdict,
490        intelligence.summary.findings,
491        intelligence.summary.risky_changes,
492        intelligence.summary.high_confidence_deletes,
493        intelligence.summary.review_required,
494        intelligence.summary.refactor_carefully,
495        intelligence.summary.skipped_ambiguous_matches,
496    )];
497    for finding in &intelligence.findings {
498        let relative = normalize_uri(&relative_path(&finding.path, root).display().to_string());
499        let identity = finding.identity.as_deref().unwrap_or("-");
500        let signals = finding
501            .signals
502            .iter()
503            .map(ToString::to_string)
504            .collect::<Vec<_>>()
505            .join("+");
506        lines.push(format!(
507            "coverage-intelligence:{}:{}:{}:id={},verdict={},recommendation={},confidence={},signals={}",
508            relative,
509            finding.line,
510            identity,
511            finding.id,
512            finding.verdict,
513            finding.recommendation,
514            finding.confidence,
515            signals,
516        ));
517    }
518    lines
519}
520
521pub(super) fn print_duplication_compact(report: &DuplicationReport, root: &Path) {
522    for (i, group) in report.clone_groups.iter().enumerate() {
523        for instance in &group.instances {
524            let relative =
525                normalize_uri(&relative_path(&instance.file, root).display().to_string());
526            outln!(
527                "clone-group-{}:{}:{}-{}:{}tokens",
528                i + 1,
529                relative,
530                instance.start_line,
531                instance.end_line,
532                group.token_count
533            );
534        }
535    }
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541    use crate::health_types::{
542        RuntimeCoverageConfidence, RuntimeCoverageDataSource, RuntimeCoverageEvidence,
543        RuntimeCoverageFinding, RuntimeCoverageHotPath, RuntimeCoverageReport,
544        RuntimeCoverageReportVerdict, RuntimeCoverageSchemaVersion, RuntimeCoverageSummary,
545        RuntimeCoverageVerdict,
546    };
547    use crate::report::test_helpers::sample_results;
548    use fallow_core::extract::MemberKind;
549    use fallow_core::results::*;
550    use std::path::PathBuf;
551
552    #[test]
553    fn compact_empty_results_no_lines() {
554        let root = PathBuf::from("/project");
555        let results = AnalysisResults::default();
556        let lines = build_compact_lines(&results, &root);
557        assert!(lines.is_empty());
558    }
559
560    #[test]
561    fn compact_unused_file_format() {
562        let root = PathBuf::from("/project");
563        let mut results = AnalysisResults::default();
564        results
565            .unused_files
566            .push(UnusedFileFinding::with_actions(UnusedFile {
567                path: root.join("src/dead.ts"),
568            }));
569
570        let lines = build_compact_lines(&results, &root);
571        assert_eq!(lines.len(), 1);
572        assert_eq!(lines[0], "unused-file:src/dead.ts");
573    }
574
575    #[test]
576    fn compact_unused_export_format() {
577        let root = PathBuf::from("/project");
578        let mut results = AnalysisResults::default();
579        results
580            .unused_exports
581            .push(UnusedExportFinding::with_actions(UnusedExport {
582                path: root.join("src/utils.ts"),
583                export_name: "helperFn".to_string(),
584                is_type_only: false,
585                line: 10,
586                col: 4,
587                span_start: 120,
588                is_re_export: false,
589            }));
590
591        let lines = build_compact_lines(&results, &root);
592        assert_eq!(lines[0], "unused-export:src/utils.ts:10:helperFn");
593    }
594
595    #[test]
596    fn compact_health_includes_runtime_coverage_lines() {
597        let root = PathBuf::from("/project");
598        let report = crate::health_types::HealthReport {
599            runtime_coverage: Some(RuntimeCoverageReport {
600                schema_version: RuntimeCoverageSchemaVersion::V1,
601                verdict: RuntimeCoverageReportVerdict::ColdCodeDetected,
602                signals: Vec::new(),
603                summary: RuntimeCoverageSummary {
604                    data_source: RuntimeCoverageDataSource::Local,
605                    last_received_at: None,
606                    functions_tracked: 4,
607                    functions_hit: 2,
608                    functions_unhit: 1,
609                    functions_untracked: 1,
610                    coverage_percent: 50.0,
611                    trace_count: 512,
612                    period_days: 7,
613                    deployments_seen: 2,
614                    capture_quality: None,
615                },
616                findings: vec![RuntimeCoverageFinding {
617                    id: "fallow:prod:deadbeef".to_owned(),
618                    stable_id: None,
619                    path: root.join("src/cold.ts"),
620                    function: "coldPath".to_owned(),
621                    line: 14,
622                    verdict: RuntimeCoverageVerdict::ReviewRequired,
623                    invocations: Some(0),
624                    confidence: RuntimeCoverageConfidence::Medium,
625                    evidence: RuntimeCoverageEvidence {
626                        static_status: "used".to_owned(),
627                        test_coverage: "not_covered".to_owned(),
628                        v8_tracking: "tracked".to_owned(),
629                        untracked_reason: None,
630                        observation_days: 7,
631                        deployments_observed: 2,
632                    },
633                    actions: vec![],
634                    source_hash: None,
635                }],
636                hot_paths: vec![RuntimeCoverageHotPath {
637                    id: "fallow:hot:cafebabe".to_owned(),
638                    stable_id: None,
639                    path: root.join("src/hot.ts"),
640                    function: "hotPath".to_owned(),
641                    line: 3,
642                    end_line: 9,
643                    invocations: 250,
644                    percentile: 99,
645                    actions: vec![],
646                }],
647                blast_radius: vec![],
648                importance: vec![],
649                watermark: None,
650                warnings: vec![],
651            }),
652            ..Default::default()
653        };
654
655        let lines = build_runtime_coverage_compact_lines(
656            report
657                .runtime_coverage
658                .as_ref()
659                .expect("runtime coverage should be set"),
660            &root,
661        );
662        assert_eq!(
663            lines[0],
664            "runtime-coverage-summary:functions_tracked=4,functions_hit=2,functions_unhit=1,functions_untracked=1,coverage_percent=50.0,trace_count=512,period_days=7,deployments_seen=2"
665        );
666        assert_eq!(
667            lines[1],
668            "runtime-coverage:src/cold.ts:14:coldPath:id=fallow:prod:deadbeef,verdict=review_required,invocations=0,confidence=medium"
669        );
670        assert_eq!(
671            lines[2],
672            "production-hot-path:src/hot.ts:3:hotPath:id=fallow:hot:cafebabe,invocations=250,percentile=99"
673        );
674    }
675
676    #[test]
677    fn compact_health_includes_coverage_intelligence_lines() {
678        use crate::health_types::{
679            CoverageIntelligenceAction, CoverageIntelligenceConfidence,
680            CoverageIntelligenceEvidence, CoverageIntelligenceFinding,
681            CoverageIntelligenceMatchConfidence, CoverageIntelligenceRecommendation,
682            CoverageIntelligenceReport, CoverageIntelligenceSchemaVersion,
683            CoverageIntelligenceSignal, CoverageIntelligenceSummary, CoverageIntelligenceVerdict,
684        };
685
686        let root = PathBuf::from("/project");
687        let report = CoverageIntelligenceReport {
688            schema_version: CoverageIntelligenceSchemaVersion::V1,
689            verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
690            summary: CoverageIntelligenceSummary {
691                findings: 1,
692                high_confidence_deletes: 1,
693                ..Default::default()
694            },
695            findings: vec![CoverageIntelligenceFinding {
696                id: "fallow:coverage-intel:abc123".to_owned(),
697                path: root.join("src/dead.ts"),
698                identity: Some("deadPath".to_owned()),
699                line: 9,
700                verdict: CoverageIntelligenceVerdict::HighConfidenceDelete,
701                signals: vec![
702                    CoverageIntelligenceSignal::StaticUnused,
703                    CoverageIntelligenceSignal::RuntimeCold,
704                ],
705                recommendation: CoverageIntelligenceRecommendation::DeleteAfterConfirmingOwner,
706                confidence: CoverageIntelligenceConfidence::High,
707                related_ids: vec!["fallow:prod:deadbeef".to_owned()],
708                evidence: CoverageIntelligenceEvidence {
709                    match_confidence: CoverageIntelligenceMatchConfidence::Direct,
710                    ..Default::default()
711                },
712                actions: vec![CoverageIntelligenceAction {
713                    kind: "delete-after-confirming-owner".to_owned(),
714                    description: "Confirm ownership".to_owned(),
715                    auto_fixable: false,
716                }],
717            }],
718        };
719
720        let lines = build_coverage_intelligence_compact_lines(&report, &root);
721        assert_eq!(
722            lines[0],
723            "coverage-intelligence-summary:verdict=high-confidence-delete,findings=1,risky_changes=0,high_confidence_deletes=1,review_required=0,refactor_carefully=0,skipped_ambiguous_matches=0"
724        );
725        assert_eq!(
726            lines[1],
727            "coverage-intelligence:src/dead.ts:9:deadPath:id=fallow:coverage-intel:abc123,verdict=high-confidence-delete,recommendation=delete-after-confirming-owner,confidence=high,signals=static_unused+runtime_cold"
728        );
729    }
730
731    #[test]
732    fn compact_unused_type_format() {
733        let root = PathBuf::from("/project");
734        let mut results = AnalysisResults::default();
735        results
736            .unused_types
737            .push(UnusedTypeFinding::with_actions(UnusedExport {
738                path: root.join("src/types.ts"),
739                export_name: "OldType".to_string(),
740                is_type_only: true,
741                line: 5,
742                col: 0,
743                span_start: 60,
744                is_re_export: false,
745            }));
746
747        let lines = build_compact_lines(&results, &root);
748        assert_eq!(lines[0], "unused-type:src/types.ts:5:OldType");
749    }
750
751    #[test]
752    fn compact_unused_dep_format() {
753        let root = PathBuf::from("/project");
754        let mut results = AnalysisResults::default();
755        results
756            .unused_dependencies
757            .push(UnusedDependencyFinding::with_actions(UnusedDependency {
758                package_name: "lodash".to_string(),
759                location: DependencyLocation::Dependencies,
760                path: root.join("package.json"),
761                line: 5,
762                used_in_workspaces: Vec::new(),
763            }));
764
765        let lines = build_compact_lines(&results, &root);
766        assert_eq!(lines[0], "unused-dep:lodash");
767    }
768
769    #[test]
770    fn compact_unused_devdep_format() {
771        let root = PathBuf::from("/project");
772        let mut results = AnalysisResults::default();
773        results
774            .unused_dev_dependencies
775            .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
776                package_name: "jest".to_string(),
777                location: DependencyLocation::DevDependencies,
778                path: root.join("package.json"),
779                line: 5,
780                used_in_workspaces: Vec::new(),
781            }));
782
783        let lines = build_compact_lines(&results, &root);
784        assert_eq!(lines[0], "unused-devdep:jest");
785    }
786
787    #[test]
788    fn compact_unused_enum_member_format() {
789        let root = PathBuf::from("/project");
790        let mut results = AnalysisResults::default();
791        results
792            .unused_enum_members
793            .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
794                path: root.join("src/enums.ts"),
795                parent_name: "Status".to_string(),
796                member_name: "Deprecated".to_string(),
797                kind: MemberKind::EnumMember,
798                line: 8,
799                col: 2,
800            }));
801
802        let lines = build_compact_lines(&results, &root);
803        assert_eq!(
804            lines[0],
805            "unused-enum-member:src/enums.ts:8:Status.Deprecated"
806        );
807    }
808
809    #[test]
810    fn compact_unused_class_member_format() {
811        let root = PathBuf::from("/project");
812        let mut results = AnalysisResults::default();
813        results
814            .unused_class_members
815            .push(UnusedClassMemberFinding::with_actions(UnusedMember {
816                path: root.join("src/service.ts"),
817                parent_name: "UserService".to_string(),
818                member_name: "legacyMethod".to_string(),
819                kind: MemberKind::ClassMethod,
820                line: 42,
821                col: 4,
822            }));
823
824        let lines = build_compact_lines(&results, &root);
825        assert_eq!(
826            lines[0],
827            "unused-class-member:src/service.ts:42:UserService.legacyMethod"
828        );
829    }
830
831    #[test]
832    fn compact_unresolved_import_format() {
833        let root = PathBuf::from("/project");
834        let mut results = AnalysisResults::default();
835        results
836            .unresolved_imports
837            .push(UnresolvedImportFinding::with_actions(UnresolvedImport {
838                path: root.join("src/app.ts"),
839                specifier: "./missing-module".to_string(),
840                line: 3,
841                col: 0,
842                specifier_col: 0,
843            }));
844
845        let lines = build_compact_lines(&results, &root);
846        assert_eq!(lines[0], "unresolved-import:src/app.ts:3:./missing-module");
847    }
848
849    #[test]
850    fn compact_unlisted_dep_format() {
851        let root = PathBuf::from("/project");
852        let mut results = AnalysisResults::default();
853        results
854            .unlisted_dependencies
855            .push(UnlistedDependencyFinding::with_actions(
856                UnlistedDependency {
857                    package_name: "chalk".to_string(),
858                    imported_from: vec![],
859                },
860            ));
861
862        let lines = build_compact_lines(&results, &root);
863        assert_eq!(lines[0], "unlisted-dep:chalk");
864    }
865
866    #[test]
867    fn compact_duplicate_export_format() {
868        let root = PathBuf::from("/project");
869        let mut results = AnalysisResults::default();
870        results
871            .duplicate_exports
872            .push(DuplicateExportFinding::with_actions(DuplicateExport {
873                export_name: "Config".to_string(),
874                locations: vec![
875                    DuplicateLocation {
876                        path: root.join("src/a.ts"),
877                        line: 15,
878                        col: 0,
879                    },
880                    DuplicateLocation {
881                        path: root.join("src/b.ts"),
882                        line: 30,
883                        col: 0,
884                    },
885                ],
886            }));
887
888        let lines = build_compact_lines(&results, &root);
889        assert_eq!(lines[0], "duplicate-export:Config");
890    }
891
892    #[test]
893    fn compact_all_issue_types_produce_lines() {
894        let root = PathBuf::from("/project");
895        let results = sample_results(&root);
896        let lines = build_compact_lines(&results, &root);
897
898        assert_eq!(lines.len(), 16);
899
900        assert!(lines[0].starts_with("unused-file:"));
901        assert!(lines[1].starts_with("unused-export:"));
902        assert!(lines[2].starts_with("unused-type:"));
903        assert!(lines[3].starts_with("unused-dep:"));
904        assert!(lines[4].starts_with("unused-devdep:"));
905        assert!(lines[5].starts_with("unused-optionaldep:"));
906        assert!(lines[6].starts_with("unused-enum-member:"));
907        assert!(lines[7].starts_with("unused-class-member:"));
908        assert!(lines[8].starts_with("unresolved-import:"));
909        assert!(lines[9].starts_with("unlisted-dep:"));
910        assert!(lines[10].starts_with("duplicate-export:"));
911        assert!(lines[11].starts_with("type-only-dep:"));
912        assert!(lines[12].starts_with("test-only-dep:"));
913        assert!(lines[13].starts_with("circular-dependency:"));
914        assert!(lines[14].starts_with("boundary-violation:"));
915    }
916
917    #[test]
918    fn compact_strips_root_prefix_from_paths() {
919        let root = PathBuf::from("/project");
920        let mut results = AnalysisResults::default();
921        results
922            .unused_files
923            .push(UnusedFileFinding::with_actions(UnusedFile {
924                path: PathBuf::from("/project/src/deep/nested/file.ts"),
925            }));
926
927        let lines = build_compact_lines(&results, &root);
928        assert_eq!(lines[0], "unused-file:src/deep/nested/file.ts");
929    }
930
931    #[test]
932    fn compact_re_export_tagged_correctly() {
933        let root = PathBuf::from("/project");
934        let mut results = AnalysisResults::default();
935        results
936            .unused_exports
937            .push(UnusedExportFinding::with_actions(UnusedExport {
938                path: root.join("src/index.ts"),
939                export_name: "reExported".to_string(),
940                is_type_only: false,
941                line: 1,
942                col: 0,
943                span_start: 0,
944                is_re_export: true,
945            }));
946
947        let lines = build_compact_lines(&results, &root);
948        assert_eq!(lines[0], "unused-re-export:src/index.ts:1:reExported");
949    }
950
951    #[test]
952    fn compact_type_re_export_tagged_correctly() {
953        let root = PathBuf::from("/project");
954        let mut results = AnalysisResults::default();
955        results
956            .unused_types
957            .push(UnusedTypeFinding::with_actions(UnusedExport {
958                path: root.join("src/index.ts"),
959                export_name: "ReExportedType".to_string(),
960                is_type_only: true,
961                line: 3,
962                col: 0,
963                span_start: 0,
964                is_re_export: true,
965            }));
966
967        let lines = build_compact_lines(&results, &root);
968        assert_eq!(
969            lines[0],
970            "unused-re-export-type:src/index.ts:3:ReExportedType"
971        );
972    }
973
974    #[test]
975    fn compact_unused_optional_dep_format() {
976        let root = PathBuf::from("/project");
977        let mut results = AnalysisResults::default();
978        results
979            .unused_optional_dependencies
980            .push(UnusedOptionalDependencyFinding::with_actions(
981                UnusedDependency {
982                    package_name: "fsevents".to_string(),
983                    location: DependencyLocation::OptionalDependencies,
984                    path: root.join("package.json"),
985                    line: 12,
986                    used_in_workspaces: Vec::new(),
987                },
988            ));
989
990        let lines = build_compact_lines(&results, &root);
991        assert_eq!(lines[0], "unused-optionaldep:fsevents");
992    }
993
994    #[test]
995    fn compact_circular_dependency_format() {
996        let root = PathBuf::from("/project");
997        let mut results = AnalysisResults::default();
998        results
999            .circular_dependencies
1000            .push(CircularDependencyFinding::with_actions(
1001                CircularDependency {
1002                    files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
1003                    length: 2,
1004                    line: 3,
1005                    col: 0,
1006                    edges: Vec::new(),
1007                    is_cross_package: false,
1008                },
1009            ));
1010
1011        let lines = build_compact_lines(&results, &root);
1012        assert_eq!(lines.len(), 1);
1013        assert!(lines[0].starts_with("circular-dependency:src/a.ts:3:"));
1014        assert!(lines[0].contains("src/a.ts"));
1015        assert!(lines[0].contains("src/b.ts"));
1016        assert!(lines[0].contains("\u{2192}"));
1017    }
1018
1019    #[test]
1020    fn compact_circular_dependency_closes_cycle() {
1021        let root = PathBuf::from("/project");
1022        let mut results = AnalysisResults::default();
1023        results
1024            .circular_dependencies
1025            .push(CircularDependencyFinding::with_actions(
1026                CircularDependency {
1027                    files: vec![
1028                        root.join("src/a.ts"),
1029                        root.join("src/b.ts"),
1030                        root.join("src/c.ts"),
1031                    ],
1032                    length: 3,
1033                    line: 1,
1034                    col: 0,
1035                    edges: Vec::new(),
1036                    is_cross_package: false,
1037                },
1038            ));
1039
1040        let lines = build_compact_lines(&results, &root);
1041        let chain_part = lines[0].split(':').next_back().unwrap();
1042        let parts: Vec<&str> = chain_part.split(" \u{2192} ").collect();
1043        assert_eq!(parts.len(), 4);
1044        assert_eq!(parts[0], parts[3]); // first == last (cycle closes)
1045    }
1046
1047    #[test]
1048    fn compact_type_only_dep_format() {
1049        let root = PathBuf::from("/project");
1050        let mut results = AnalysisResults::default();
1051        results
1052            .type_only_dependencies
1053            .push(TypeOnlyDependencyFinding::with_actions(
1054                TypeOnlyDependency {
1055                    package_name: "zod".to_string(),
1056                    path: root.join("package.json"),
1057                    line: 8,
1058                },
1059            ));
1060
1061        let lines = build_compact_lines(&results, &root);
1062        assert_eq!(lines[0], "type-only-dep:zod");
1063    }
1064
1065    #[test]
1066    fn compact_multiple_unused_files() {
1067        let root = PathBuf::from("/project");
1068        let mut results = AnalysisResults::default();
1069        results
1070            .unused_files
1071            .push(UnusedFileFinding::with_actions(UnusedFile {
1072                path: root.join("src/a.ts"),
1073            }));
1074        results
1075            .unused_files
1076            .push(UnusedFileFinding::with_actions(UnusedFile {
1077                path: root.join("src/b.ts"),
1078            }));
1079
1080        let lines = build_compact_lines(&results, &root);
1081        assert_eq!(lines.len(), 2);
1082        assert_eq!(lines[0], "unused-file:src/a.ts");
1083        assert_eq!(lines[1], "unused-file:src/b.ts");
1084    }
1085
1086    #[test]
1087    fn compact_ordering_optional_dep_between_devdep_and_enum() {
1088        let root = PathBuf::from("/project");
1089        let mut results = AnalysisResults::default();
1090        results
1091            .unused_dev_dependencies
1092            .push(UnusedDevDependencyFinding::with_actions(UnusedDependency {
1093                package_name: "jest".to_string(),
1094                location: DependencyLocation::DevDependencies,
1095                path: root.join("package.json"),
1096                line: 5,
1097                used_in_workspaces: Vec::new(),
1098            }));
1099        results
1100            .unused_optional_dependencies
1101            .push(UnusedOptionalDependencyFinding::with_actions(
1102                UnusedDependency {
1103                    package_name: "fsevents".to_string(),
1104                    location: DependencyLocation::OptionalDependencies,
1105                    path: root.join("package.json"),
1106                    line: 12,
1107                    used_in_workspaces: Vec::new(),
1108                },
1109            ));
1110        results
1111            .unused_enum_members
1112            .push(UnusedEnumMemberFinding::with_actions(UnusedMember {
1113                path: root.join("src/enums.ts"),
1114                parent_name: "Status".to_string(),
1115                member_name: "Deprecated".to_string(),
1116                kind: MemberKind::EnumMember,
1117                line: 8,
1118                col: 2,
1119            }));
1120
1121        let lines = build_compact_lines(&results, &root);
1122        assert_eq!(lines.len(), 3);
1123        assert!(lines[0].starts_with("unused-devdep:"));
1124        assert!(lines[1].starts_with("unused-optionaldep:"));
1125        assert!(lines[2].starts_with("unused-enum-member:"));
1126    }
1127
1128    #[test]
1129    fn compact_path_outside_root_preserved() {
1130        let root = PathBuf::from("/project");
1131        let mut results = AnalysisResults::default();
1132        results
1133            .unused_files
1134            .push(UnusedFileFinding::with_actions(UnusedFile {
1135                path: PathBuf::from("/other/place/file.ts"),
1136            }));
1137
1138        let lines = build_compact_lines(&results, &root);
1139        assert!(lines[0].contains("/other/place/file.ts"));
1140    }
1141}