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 dep in &results.unused_dependencies {
58        lines.push(format!("unused-dep:{}", dep.package_name));
59    }
60    for dep in &results.unused_dev_dependencies {
61        lines.push(format!("unused-devdep:{}", dep.package_name));
62    }
63    for dep in &results.unused_optional_dependencies {
64        lines.push(format!("unused-optionaldep:{}", dep.package_name));
65    }
66    for member in &results.unused_enum_members {
67        lines.push(compact_member(member, "unused-enum-member"));
68    }
69    for member in &results.unused_class_members {
70        lines.push(compact_member(member, "unused-class-member"));
71    }
72    for import in &results.unresolved_imports {
73        lines.push(format!(
74            "unresolved-import:{}:{}:{}",
75            rel(&import.path),
76            import.line,
77            import.specifier
78        ));
79    }
80    for dep in &results.unlisted_dependencies {
81        lines.push(format!("unlisted-dep:{}", dep.package_name));
82    }
83    for dup in &results.duplicate_exports {
84        lines.push(format!("duplicate-export:{}", dup.export_name));
85    }
86    for dep in &results.type_only_dependencies {
87        lines.push(format!("type-only-dep:{}", dep.package_name));
88    }
89    for dep in &results.test_only_dependencies {
90        lines.push(format!("test-only-dep:{}", dep.package_name));
91    }
92    for cycle in &results.circular_dependencies {
93        let chain: Vec<String> = cycle.files.iter().map(|p| rel(p)).collect();
94        let mut display_chain = chain.clone();
95        if let Some(first) = chain.first() {
96            display_chain.push(first.clone());
97        }
98        let first_file = chain.first().map_or_else(String::new, Clone::clone);
99        let cross_pkg_tag = if cycle.is_cross_package {
100            " (cross-package)"
101        } else {
102            ""
103        };
104        lines.push(format!(
105            "circular-dependency:{}:{}:{}{}",
106            first_file,
107            cycle.line,
108            display_chain.join(" \u{2192} "),
109            cross_pkg_tag
110        ));
111    }
112    for v in &results.boundary_violations {
113        lines.push(format!(
114            "boundary-violation:{}:{}:{} -> {} ({} -> {})",
115            rel(&v.from_path),
116            v.line,
117            rel(&v.from_path),
118            rel(&v.to_path),
119            v.from_zone,
120            v.to_zone,
121        ));
122    }
123
124    lines
125}
126
127/// Print grouped compact output: each line is prefixed with the group key.
128///
129/// Format: `group-key\tissue-tag:details`
130pub(super) fn print_grouped_compact(groups: &[ResultGroup], root: &Path) {
131    for group in groups {
132        for line in build_compact_lines(&group.results, root) {
133            println!("{}\t{line}", group.key);
134        }
135    }
136}
137
138pub(super) fn print_health_compact(report: &crate::health_types::HealthReport, root: &Path) {
139    if let Some(ref hs) = report.health_score {
140        println!("health-score:{:.1}:{}", hs.score, hs.grade);
141    }
142    if let Some(ref vs) = report.vital_signs {
143        let mut parts = Vec::new();
144        parts.push(format!("avg_cyclomatic={:.1}", vs.avg_cyclomatic));
145        parts.push(format!("p90_cyclomatic={}", vs.p90_cyclomatic));
146        if let Some(v) = vs.dead_file_pct {
147            parts.push(format!("dead_file_pct={v:.1}"));
148        }
149        if let Some(v) = vs.dead_export_pct {
150            parts.push(format!("dead_export_pct={v:.1}"));
151        }
152        if let Some(v) = vs.maintainability_avg {
153            parts.push(format!("maintainability_avg={v:.1}"));
154        }
155        if let Some(v) = vs.hotspot_count {
156            parts.push(format!("hotspot_count={v}"));
157        }
158        if let Some(v) = vs.circular_dep_count {
159            parts.push(format!("circular_dep_count={v}"));
160        }
161        if let Some(v) = vs.unused_dep_count {
162            parts.push(format!("unused_dep_count={v}"));
163        }
164        println!("vital-signs:{}", parts.join(","));
165    }
166    for finding in &report.findings {
167        let relative = normalize_uri(&relative_path(&finding.path, root).display().to_string());
168        println!(
169            "high-complexity:{}:{}:{}:cyclomatic={},cognitive={}",
170            relative, finding.line, finding.name, finding.cyclomatic, finding.cognitive,
171        );
172    }
173    for score in &report.file_scores {
174        let relative = normalize_uri(&relative_path(&score.path, root).display().to_string());
175        println!(
176            "file-score:{}:mi={:.1},fan_in={},fan_out={},dead={:.2},density={:.2}",
177            relative,
178            score.maintainability_index,
179            score.fan_in,
180            score.fan_out,
181            score.dead_code_ratio,
182            score.complexity_density,
183        );
184    }
185    if let Some(ref gaps) = report.coverage_gaps {
186        println!(
187            "coverage-gap-summary:runtime_files={},covered_files={},file_coverage_pct={:.1},untested_files={},untested_exports={}",
188            gaps.summary.runtime_files,
189            gaps.summary.covered_files,
190            gaps.summary.file_coverage_pct,
191            gaps.summary.untested_files,
192            gaps.summary.untested_exports,
193        );
194        for item in &gaps.files {
195            let relative = normalize_uri(&relative_path(&item.path, root).display().to_string());
196            println!(
197                "untested-file:{}:value_exports={}",
198                relative, item.value_export_count,
199            );
200        }
201        for item in &gaps.exports {
202            let relative = normalize_uri(&relative_path(&item.path, root).display().to_string());
203            println!(
204                "untested-export:{}:{}:{}",
205                relative, item.line, item.export_name,
206            );
207        }
208    }
209    for entry in &report.hotspots {
210        let relative = normalize_uri(&relative_path(&entry.path, root).display().to_string());
211        println!(
212            "hotspot:{}:score={:.1},commits={},churn={},density={:.2},fan_in={},trend={}",
213            relative,
214            entry.score,
215            entry.commits,
216            entry.lines_added + entry.lines_deleted,
217            entry.complexity_density,
218            entry.fan_in,
219            entry.trend,
220        );
221    }
222    if let Some(ref trend) = report.health_trend {
223        println!(
224            "trend:overall:direction={}",
225            trend.overall_direction.label()
226        );
227        for m in &trend.metrics {
228            println!(
229                "trend:{}:previous={:.1},current={:.1},delta={:+.1},direction={}",
230                m.name,
231                m.previous,
232                m.current,
233                m.delta,
234                m.direction.label(),
235            );
236        }
237    }
238    for target in &report.targets {
239        let relative = normalize_uri(&relative_path(&target.path, root).display().to_string());
240        let category = target.category.compact_label();
241        let effort = target.effort.label();
242        let confidence = target.confidence.label();
243        println!(
244            "refactoring-target:{}:priority={:.1},efficiency={:.1},category={},effort={},confidence={}:{}",
245            relative,
246            target.priority,
247            target.efficiency,
248            category,
249            effort,
250            confidence,
251            target.recommendation,
252        );
253    }
254}
255
256pub(super) fn print_duplication_compact(report: &DuplicationReport, root: &Path) {
257    for (i, group) in report.clone_groups.iter().enumerate() {
258        for instance in &group.instances {
259            let relative =
260                normalize_uri(&relative_path(&instance.file, root).display().to_string());
261            println!(
262                "clone-group-{}:{}:{}-{}:{}tokens",
263                i + 1,
264                relative,
265                instance.start_line,
266                instance.end_line,
267                group.token_count
268            );
269        }
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use crate::report::test_helpers::sample_results;
277    use fallow_core::extract::MemberKind;
278    use fallow_core::results::*;
279    use std::path::PathBuf;
280
281    #[test]
282    fn compact_empty_results_no_lines() {
283        let root = PathBuf::from("/project");
284        let results = AnalysisResults::default();
285        let lines = build_compact_lines(&results, &root);
286        assert!(lines.is_empty());
287    }
288
289    #[test]
290    fn compact_unused_file_format() {
291        let root = PathBuf::from("/project");
292        let mut results = AnalysisResults::default();
293        results.unused_files.push(UnusedFile {
294            path: root.join("src/dead.ts"),
295        });
296
297        let lines = build_compact_lines(&results, &root);
298        assert_eq!(lines.len(), 1);
299        assert_eq!(lines[0], "unused-file:src/dead.ts");
300    }
301
302    #[test]
303    fn compact_unused_export_format() {
304        let root = PathBuf::from("/project");
305        let mut results = AnalysisResults::default();
306        results.unused_exports.push(UnusedExport {
307            path: root.join("src/utils.ts"),
308            export_name: "helperFn".to_string(),
309            is_type_only: false,
310            line: 10,
311            col: 4,
312            span_start: 120,
313            is_re_export: false,
314        });
315
316        let lines = build_compact_lines(&results, &root);
317        assert_eq!(lines[0], "unused-export:src/utils.ts:10:helperFn");
318    }
319
320    #[test]
321    fn compact_unused_type_format() {
322        let root = PathBuf::from("/project");
323        let mut results = AnalysisResults::default();
324        results.unused_types.push(UnusedExport {
325            path: root.join("src/types.ts"),
326            export_name: "OldType".to_string(),
327            is_type_only: true,
328            line: 5,
329            col: 0,
330            span_start: 60,
331            is_re_export: false,
332        });
333
334        let lines = build_compact_lines(&results, &root);
335        assert_eq!(lines[0], "unused-type:src/types.ts:5:OldType");
336    }
337
338    #[test]
339    fn compact_unused_dep_format() {
340        let root = PathBuf::from("/project");
341        let mut results = AnalysisResults::default();
342        results.unused_dependencies.push(UnusedDependency {
343            package_name: "lodash".to_string(),
344            location: DependencyLocation::Dependencies,
345            path: root.join("package.json"),
346            line: 5,
347        });
348
349        let lines = build_compact_lines(&results, &root);
350        assert_eq!(lines[0], "unused-dep:lodash");
351    }
352
353    #[test]
354    fn compact_unused_devdep_format() {
355        let root = PathBuf::from("/project");
356        let mut results = AnalysisResults::default();
357        results.unused_dev_dependencies.push(UnusedDependency {
358            package_name: "jest".to_string(),
359            location: DependencyLocation::DevDependencies,
360            path: root.join("package.json"),
361            line: 5,
362        });
363
364        let lines = build_compact_lines(&results, &root);
365        assert_eq!(lines[0], "unused-devdep:jest");
366    }
367
368    #[test]
369    fn compact_unused_enum_member_format() {
370        let root = PathBuf::from("/project");
371        let mut results = AnalysisResults::default();
372        results.unused_enum_members.push(UnusedMember {
373            path: root.join("src/enums.ts"),
374            parent_name: "Status".to_string(),
375            member_name: "Deprecated".to_string(),
376            kind: MemberKind::EnumMember,
377            line: 8,
378            col: 2,
379        });
380
381        let lines = build_compact_lines(&results, &root);
382        assert_eq!(
383            lines[0],
384            "unused-enum-member:src/enums.ts:8:Status.Deprecated"
385        );
386    }
387
388    #[test]
389    fn compact_unused_class_member_format() {
390        let root = PathBuf::from("/project");
391        let mut results = AnalysisResults::default();
392        results.unused_class_members.push(UnusedMember {
393            path: root.join("src/service.ts"),
394            parent_name: "UserService".to_string(),
395            member_name: "legacyMethod".to_string(),
396            kind: MemberKind::ClassMethod,
397            line: 42,
398            col: 4,
399        });
400
401        let lines = build_compact_lines(&results, &root);
402        assert_eq!(
403            lines[0],
404            "unused-class-member:src/service.ts:42:UserService.legacyMethod"
405        );
406    }
407
408    #[test]
409    fn compact_unresolved_import_format() {
410        let root = PathBuf::from("/project");
411        let mut results = AnalysisResults::default();
412        results.unresolved_imports.push(UnresolvedImport {
413            path: root.join("src/app.ts"),
414            specifier: "./missing-module".to_string(),
415            line: 3,
416            col: 0,
417            specifier_col: 0,
418        });
419
420        let lines = build_compact_lines(&results, &root);
421        assert_eq!(lines[0], "unresolved-import:src/app.ts:3:./missing-module");
422    }
423
424    #[test]
425    fn compact_unlisted_dep_format() {
426        let root = PathBuf::from("/project");
427        let mut results = AnalysisResults::default();
428        results.unlisted_dependencies.push(UnlistedDependency {
429            package_name: "chalk".to_string(),
430            imported_from: vec![],
431        });
432
433        let lines = build_compact_lines(&results, &root);
434        assert_eq!(lines[0], "unlisted-dep:chalk");
435    }
436
437    #[test]
438    fn compact_duplicate_export_format() {
439        let root = PathBuf::from("/project");
440        let mut results = AnalysisResults::default();
441        results.duplicate_exports.push(DuplicateExport {
442            export_name: "Config".to_string(),
443            locations: vec![
444                DuplicateLocation {
445                    path: root.join("src/a.ts"),
446                    line: 15,
447                    col: 0,
448                },
449                DuplicateLocation {
450                    path: root.join("src/b.ts"),
451                    line: 30,
452                    col: 0,
453                },
454            ],
455        });
456
457        let lines = build_compact_lines(&results, &root);
458        assert_eq!(lines[0], "duplicate-export:Config");
459    }
460
461    #[test]
462    fn compact_all_issue_types_produce_lines() {
463        let root = PathBuf::from("/project");
464        let results = sample_results(&root);
465        let lines = build_compact_lines(&results, &root);
466
467        // 15 issue types, one of each
468        assert_eq!(lines.len(), 15);
469
470        // Verify ordering matches output order
471        assert!(lines[0].starts_with("unused-file:"));
472        assert!(lines[1].starts_with("unused-export:"));
473        assert!(lines[2].starts_with("unused-type:"));
474        assert!(lines[3].starts_with("unused-dep:"));
475        assert!(lines[4].starts_with("unused-devdep:"));
476        assert!(lines[5].starts_with("unused-optionaldep:"));
477        assert!(lines[6].starts_with("unused-enum-member:"));
478        assert!(lines[7].starts_with("unused-class-member:"));
479        assert!(lines[8].starts_with("unresolved-import:"));
480        assert!(lines[9].starts_with("unlisted-dep:"));
481        assert!(lines[10].starts_with("duplicate-export:"));
482        assert!(lines[11].starts_with("type-only-dep:"));
483        assert!(lines[12].starts_with("test-only-dep:"));
484        assert!(lines[13].starts_with("circular-dependency:"));
485        assert!(lines[14].starts_with("boundary-violation:"));
486    }
487
488    #[test]
489    fn compact_strips_root_prefix_from_paths() {
490        let root = PathBuf::from("/project");
491        let mut results = AnalysisResults::default();
492        results.unused_files.push(UnusedFile {
493            path: PathBuf::from("/project/src/deep/nested/file.ts"),
494        });
495
496        let lines = build_compact_lines(&results, &root);
497        assert_eq!(lines[0], "unused-file:src/deep/nested/file.ts");
498    }
499
500    // ── Re-export variants ──
501
502    #[test]
503    fn compact_re_export_tagged_correctly() {
504        let root = PathBuf::from("/project");
505        let mut results = AnalysisResults::default();
506        results.unused_exports.push(UnusedExport {
507            path: root.join("src/index.ts"),
508            export_name: "reExported".to_string(),
509            is_type_only: false,
510            line: 1,
511            col: 0,
512            span_start: 0,
513            is_re_export: true,
514        });
515
516        let lines = build_compact_lines(&results, &root);
517        assert_eq!(lines[0], "unused-re-export:src/index.ts:1:reExported");
518    }
519
520    #[test]
521    fn compact_type_re_export_tagged_correctly() {
522        let root = PathBuf::from("/project");
523        let mut results = AnalysisResults::default();
524        results.unused_types.push(UnusedExport {
525            path: root.join("src/index.ts"),
526            export_name: "ReExportedType".to_string(),
527            is_type_only: true,
528            line: 3,
529            col: 0,
530            span_start: 0,
531            is_re_export: true,
532        });
533
534        let lines = build_compact_lines(&results, &root);
535        assert_eq!(
536            lines[0],
537            "unused-re-export-type:src/index.ts:3:ReExportedType"
538        );
539    }
540
541    // ── Unused optional dependency ──
542
543    #[test]
544    fn compact_unused_optional_dep_format() {
545        let root = PathBuf::from("/project");
546        let mut results = AnalysisResults::default();
547        results.unused_optional_dependencies.push(UnusedDependency {
548            package_name: "fsevents".to_string(),
549            location: DependencyLocation::OptionalDependencies,
550            path: root.join("package.json"),
551            line: 12,
552        });
553
554        let lines = build_compact_lines(&results, &root);
555        assert_eq!(lines[0], "unused-optionaldep:fsevents");
556    }
557
558    // ── Circular dependency ──
559
560    #[test]
561    fn compact_circular_dependency_format() {
562        let root = PathBuf::from("/project");
563        let mut results = AnalysisResults::default();
564        results.circular_dependencies.push(CircularDependency {
565            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
566            length: 2,
567            line: 3,
568            col: 0,
569            is_cross_package: false,
570        });
571
572        let lines = build_compact_lines(&results, &root);
573        assert_eq!(lines.len(), 1);
574        assert!(lines[0].starts_with("circular-dependency:src/a.ts:3:"));
575        assert!(lines[0].contains("src/a.ts"));
576        assert!(lines[0].contains("src/b.ts"));
577        // Chain should close the cycle: a -> b -> a
578        assert!(lines[0].contains("\u{2192}"));
579    }
580
581    #[test]
582    fn compact_circular_dependency_closes_cycle() {
583        let root = PathBuf::from("/project");
584        let mut results = AnalysisResults::default();
585        results.circular_dependencies.push(CircularDependency {
586            files: vec![
587                root.join("src/a.ts"),
588                root.join("src/b.ts"),
589                root.join("src/c.ts"),
590            ],
591            length: 3,
592            line: 1,
593            col: 0,
594            is_cross_package: false,
595        });
596
597        let lines = build_compact_lines(&results, &root);
598        // Chain: a -> b -> c -> a
599        let chain_part = lines[0].split(':').next_back().unwrap();
600        let parts: Vec<&str> = chain_part.split(" \u{2192} ").collect();
601        assert_eq!(parts.len(), 4);
602        assert_eq!(parts[0], parts[3]); // first == last (cycle closes)
603    }
604
605    // ── Type-only dependency ──
606
607    #[test]
608    fn compact_type_only_dep_format() {
609        let root = PathBuf::from("/project");
610        let mut results = AnalysisResults::default();
611        results.type_only_dependencies.push(TypeOnlyDependency {
612            package_name: "zod".to_string(),
613            path: root.join("package.json"),
614            line: 8,
615        });
616
617        let lines = build_compact_lines(&results, &root);
618        assert_eq!(lines[0], "type-only-dep:zod");
619    }
620
621    // ── Multiple items of same type ──
622
623    #[test]
624    fn compact_multiple_unused_files() {
625        let root = PathBuf::from("/project");
626        let mut results = AnalysisResults::default();
627        results.unused_files.push(UnusedFile {
628            path: root.join("src/a.ts"),
629        });
630        results.unused_files.push(UnusedFile {
631            path: root.join("src/b.ts"),
632        });
633
634        let lines = build_compact_lines(&results, &root);
635        assert_eq!(lines.len(), 2);
636        assert_eq!(lines[0], "unused-file:src/a.ts");
637        assert_eq!(lines[1], "unused-file:src/b.ts");
638    }
639
640    // ── Output ordering matches issue types ──
641
642    #[test]
643    fn compact_ordering_optional_dep_between_devdep_and_enum() {
644        let root = PathBuf::from("/project");
645        let mut results = AnalysisResults::default();
646        results.unused_dev_dependencies.push(UnusedDependency {
647            package_name: "jest".to_string(),
648            location: DependencyLocation::DevDependencies,
649            path: root.join("package.json"),
650            line: 5,
651        });
652        results.unused_optional_dependencies.push(UnusedDependency {
653            package_name: "fsevents".to_string(),
654            location: DependencyLocation::OptionalDependencies,
655            path: root.join("package.json"),
656            line: 12,
657        });
658        results.unused_enum_members.push(UnusedMember {
659            path: root.join("src/enums.ts"),
660            parent_name: "Status".to_string(),
661            member_name: "Deprecated".to_string(),
662            kind: MemberKind::EnumMember,
663            line: 8,
664            col: 2,
665        });
666
667        let lines = build_compact_lines(&results, &root);
668        assert_eq!(lines.len(), 3);
669        assert!(lines[0].starts_with("unused-devdep:"));
670        assert!(lines[1].starts_with("unused-optionaldep:"));
671        assert!(lines[2].starts_with("unused-enum-member:"));
672    }
673
674    // ── Path outside root ──
675
676    #[test]
677    fn compact_path_outside_root_preserved() {
678        let root = PathBuf::from("/project");
679        let mut results = AnalysisResults::default();
680        results.unused_files.push(UnusedFile {
681            path: PathBuf::from("/other/place/file.ts"),
682        });
683
684        let lines = build_compact_lines(&results, &root);
685        assert!(lines[0].contains("/other/place/file.ts"));
686    }
687}