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