Skip to main content

fallow_cli/report/
compact.rs

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