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    for entry in &report.hotspots {
186        let relative = normalize_uri(&relative_path(&entry.path, root).display().to_string());
187        println!(
188            "hotspot:{}:score={:.1},commits={},churn={},density={:.2},fan_in={},trend={}",
189            relative,
190            entry.score,
191            entry.commits,
192            entry.lines_added + entry.lines_deleted,
193            entry.complexity_density,
194            entry.fan_in,
195            entry.trend,
196        );
197    }
198    if let Some(ref trend) = report.health_trend {
199        println!(
200            "trend:overall:direction={}",
201            trend.overall_direction.label()
202        );
203        for m in &trend.metrics {
204            println!(
205                "trend:{}:previous={:.1},current={:.1},delta={:+.1},direction={}",
206                m.name,
207                m.previous,
208                m.current,
209                m.delta,
210                m.direction.label(),
211            );
212        }
213    }
214    for target in &report.targets {
215        let relative = normalize_uri(&relative_path(&target.path, root).display().to_string());
216        let category = target.category.compact_label();
217        let effort = target.effort.label();
218        let confidence = target.confidence.label();
219        println!(
220            "refactoring-target:{}:priority={:.1},efficiency={:.1},category={},effort={},confidence={}:{}",
221            relative,
222            target.priority,
223            target.efficiency,
224            category,
225            effort,
226            confidence,
227            target.recommendation,
228        );
229    }
230}
231
232pub(super) fn print_duplication_compact(report: &DuplicationReport, root: &Path) {
233    for (i, group) in report.clone_groups.iter().enumerate() {
234        for instance in &group.instances {
235            let relative =
236                normalize_uri(&relative_path(&instance.file, root).display().to_string());
237            println!(
238                "clone-group-{}:{}:{}-{}:{}tokens",
239                i + 1,
240                relative,
241                instance.start_line,
242                instance.end_line,
243                group.token_count
244            );
245        }
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use crate::report::test_helpers::sample_results;
253    use fallow_core::extract::MemberKind;
254    use fallow_core::results::*;
255    use std::path::PathBuf;
256
257    #[test]
258    fn compact_empty_results_no_lines() {
259        let root = PathBuf::from("/project");
260        let results = AnalysisResults::default();
261        let lines = build_compact_lines(&results, &root);
262        assert!(lines.is_empty());
263    }
264
265    #[test]
266    fn compact_unused_file_format() {
267        let root = PathBuf::from("/project");
268        let mut results = AnalysisResults::default();
269        results.unused_files.push(UnusedFile {
270            path: root.join("src/dead.ts"),
271        });
272
273        let lines = build_compact_lines(&results, &root);
274        assert_eq!(lines.len(), 1);
275        assert_eq!(lines[0], "unused-file:src/dead.ts");
276    }
277
278    #[test]
279    fn compact_unused_export_format() {
280        let root = PathBuf::from("/project");
281        let mut results = AnalysisResults::default();
282        results.unused_exports.push(UnusedExport {
283            path: root.join("src/utils.ts"),
284            export_name: "helperFn".to_string(),
285            is_type_only: false,
286            line: 10,
287            col: 4,
288            span_start: 120,
289            is_re_export: false,
290        });
291
292        let lines = build_compact_lines(&results, &root);
293        assert_eq!(lines[0], "unused-export:src/utils.ts:10:helperFn");
294    }
295
296    #[test]
297    fn compact_unused_type_format() {
298        let root = PathBuf::from("/project");
299        let mut results = AnalysisResults::default();
300        results.unused_types.push(UnusedExport {
301            path: root.join("src/types.ts"),
302            export_name: "OldType".to_string(),
303            is_type_only: true,
304            line: 5,
305            col: 0,
306            span_start: 60,
307            is_re_export: false,
308        });
309
310        let lines = build_compact_lines(&results, &root);
311        assert_eq!(lines[0], "unused-type:src/types.ts:5:OldType");
312    }
313
314    #[test]
315    fn compact_unused_dep_format() {
316        let root = PathBuf::from("/project");
317        let mut results = AnalysisResults::default();
318        results.unused_dependencies.push(UnusedDependency {
319            package_name: "lodash".to_string(),
320            location: DependencyLocation::Dependencies,
321            path: root.join("package.json"),
322            line: 5,
323        });
324
325        let lines = build_compact_lines(&results, &root);
326        assert_eq!(lines[0], "unused-dep:lodash");
327    }
328
329    #[test]
330    fn compact_unused_devdep_format() {
331        let root = PathBuf::from("/project");
332        let mut results = AnalysisResults::default();
333        results.unused_dev_dependencies.push(UnusedDependency {
334            package_name: "jest".to_string(),
335            location: DependencyLocation::DevDependencies,
336            path: root.join("package.json"),
337            line: 5,
338        });
339
340        let lines = build_compact_lines(&results, &root);
341        assert_eq!(lines[0], "unused-devdep:jest");
342    }
343
344    #[test]
345    fn compact_unused_enum_member_format() {
346        let root = PathBuf::from("/project");
347        let mut results = AnalysisResults::default();
348        results.unused_enum_members.push(UnusedMember {
349            path: root.join("src/enums.ts"),
350            parent_name: "Status".to_string(),
351            member_name: "Deprecated".to_string(),
352            kind: MemberKind::EnumMember,
353            line: 8,
354            col: 2,
355        });
356
357        let lines = build_compact_lines(&results, &root);
358        assert_eq!(
359            lines[0],
360            "unused-enum-member:src/enums.ts:8:Status.Deprecated"
361        );
362    }
363
364    #[test]
365    fn compact_unused_class_member_format() {
366        let root = PathBuf::from("/project");
367        let mut results = AnalysisResults::default();
368        results.unused_class_members.push(UnusedMember {
369            path: root.join("src/service.ts"),
370            parent_name: "UserService".to_string(),
371            member_name: "legacyMethod".to_string(),
372            kind: MemberKind::ClassMethod,
373            line: 42,
374            col: 4,
375        });
376
377        let lines = build_compact_lines(&results, &root);
378        assert_eq!(
379            lines[0],
380            "unused-class-member:src/service.ts:42:UserService.legacyMethod"
381        );
382    }
383
384    #[test]
385    fn compact_unresolved_import_format() {
386        let root = PathBuf::from("/project");
387        let mut results = AnalysisResults::default();
388        results.unresolved_imports.push(UnresolvedImport {
389            path: root.join("src/app.ts"),
390            specifier: "./missing-module".to_string(),
391            line: 3,
392            col: 0,
393            specifier_col: 0,
394        });
395
396        let lines = build_compact_lines(&results, &root);
397        assert_eq!(lines[0], "unresolved-import:src/app.ts:3:./missing-module");
398    }
399
400    #[test]
401    fn compact_unlisted_dep_format() {
402        let root = PathBuf::from("/project");
403        let mut results = AnalysisResults::default();
404        results.unlisted_dependencies.push(UnlistedDependency {
405            package_name: "chalk".to_string(),
406            imported_from: vec![],
407        });
408
409        let lines = build_compact_lines(&results, &root);
410        assert_eq!(lines[0], "unlisted-dep:chalk");
411    }
412
413    #[test]
414    fn compact_duplicate_export_format() {
415        let root = PathBuf::from("/project");
416        let mut results = AnalysisResults::default();
417        results.duplicate_exports.push(DuplicateExport {
418            export_name: "Config".to_string(),
419            locations: vec![
420                DuplicateLocation {
421                    path: root.join("src/a.ts"),
422                    line: 15,
423                    col: 0,
424                },
425                DuplicateLocation {
426                    path: root.join("src/b.ts"),
427                    line: 30,
428                    col: 0,
429                },
430            ],
431        });
432
433        let lines = build_compact_lines(&results, &root);
434        assert_eq!(lines[0], "duplicate-export:Config");
435    }
436
437    #[test]
438    fn compact_all_issue_types_produce_lines() {
439        let root = PathBuf::from("/project");
440        let results = sample_results(&root);
441        let lines = build_compact_lines(&results, &root);
442
443        // 15 issue types, one of each
444        assert_eq!(lines.len(), 15);
445
446        // Verify ordering matches output order
447        assert!(lines[0].starts_with("unused-file:"));
448        assert!(lines[1].starts_with("unused-export:"));
449        assert!(lines[2].starts_with("unused-type:"));
450        assert!(lines[3].starts_with("unused-dep:"));
451        assert!(lines[4].starts_with("unused-devdep:"));
452        assert!(lines[5].starts_with("unused-optionaldep:"));
453        assert!(lines[6].starts_with("unused-enum-member:"));
454        assert!(lines[7].starts_with("unused-class-member:"));
455        assert!(lines[8].starts_with("unresolved-import:"));
456        assert!(lines[9].starts_with("unlisted-dep:"));
457        assert!(lines[10].starts_with("duplicate-export:"));
458        assert!(lines[11].starts_with("type-only-dep:"));
459        assert!(lines[12].starts_with("test-only-dep:"));
460        assert!(lines[13].starts_with("circular-dependency:"));
461        assert!(lines[14].starts_with("boundary-violation:"));
462    }
463
464    #[test]
465    fn compact_strips_root_prefix_from_paths() {
466        let root = PathBuf::from("/project");
467        let mut results = AnalysisResults::default();
468        results.unused_files.push(UnusedFile {
469            path: PathBuf::from("/project/src/deep/nested/file.ts"),
470        });
471
472        let lines = build_compact_lines(&results, &root);
473        assert_eq!(lines[0], "unused-file:src/deep/nested/file.ts");
474    }
475
476    // ── Re-export variants ──
477
478    #[test]
479    fn compact_re_export_tagged_correctly() {
480        let root = PathBuf::from("/project");
481        let mut results = AnalysisResults::default();
482        results.unused_exports.push(UnusedExport {
483            path: root.join("src/index.ts"),
484            export_name: "reExported".to_string(),
485            is_type_only: false,
486            line: 1,
487            col: 0,
488            span_start: 0,
489            is_re_export: true,
490        });
491
492        let lines = build_compact_lines(&results, &root);
493        assert_eq!(lines[0], "unused-re-export:src/index.ts:1:reExported");
494    }
495
496    #[test]
497    fn compact_type_re_export_tagged_correctly() {
498        let root = PathBuf::from("/project");
499        let mut results = AnalysisResults::default();
500        results.unused_types.push(UnusedExport {
501            path: root.join("src/index.ts"),
502            export_name: "ReExportedType".to_string(),
503            is_type_only: true,
504            line: 3,
505            col: 0,
506            span_start: 0,
507            is_re_export: true,
508        });
509
510        let lines = build_compact_lines(&results, &root);
511        assert_eq!(
512            lines[0],
513            "unused-re-export-type:src/index.ts:3:ReExportedType"
514        );
515    }
516
517    // ── Unused optional dependency ──
518
519    #[test]
520    fn compact_unused_optional_dep_format() {
521        let root = PathBuf::from("/project");
522        let mut results = AnalysisResults::default();
523        results.unused_optional_dependencies.push(UnusedDependency {
524            package_name: "fsevents".to_string(),
525            location: DependencyLocation::OptionalDependencies,
526            path: root.join("package.json"),
527            line: 12,
528        });
529
530        let lines = build_compact_lines(&results, &root);
531        assert_eq!(lines[0], "unused-optionaldep:fsevents");
532    }
533
534    // ── Circular dependency ──
535
536    #[test]
537    fn compact_circular_dependency_format() {
538        let root = PathBuf::from("/project");
539        let mut results = AnalysisResults::default();
540        results.circular_dependencies.push(CircularDependency {
541            files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
542            length: 2,
543            line: 3,
544            col: 0,
545            is_cross_package: false,
546        });
547
548        let lines = build_compact_lines(&results, &root);
549        assert_eq!(lines.len(), 1);
550        assert!(lines[0].starts_with("circular-dependency:src/a.ts:3:"));
551        assert!(lines[0].contains("src/a.ts"));
552        assert!(lines[0].contains("src/b.ts"));
553        // Chain should close the cycle: a -> b -> a
554        assert!(lines[0].contains("\u{2192}"));
555    }
556
557    #[test]
558    fn compact_circular_dependency_closes_cycle() {
559        let root = PathBuf::from("/project");
560        let mut results = AnalysisResults::default();
561        results.circular_dependencies.push(CircularDependency {
562            files: vec![
563                root.join("src/a.ts"),
564                root.join("src/b.ts"),
565                root.join("src/c.ts"),
566            ],
567            length: 3,
568            line: 1,
569            col: 0,
570            is_cross_package: false,
571        });
572
573        let lines = build_compact_lines(&results, &root);
574        // Chain: a -> b -> c -> a
575        let chain_part = lines[0].split(':').next_back().unwrap();
576        let parts: Vec<&str> = chain_part.split(" \u{2192} ").collect();
577        assert_eq!(parts.len(), 4);
578        assert_eq!(parts[0], parts[3]); // first == last (cycle closes)
579    }
580
581    // ── Type-only dependency ──
582
583    #[test]
584    fn compact_type_only_dep_format() {
585        let root = PathBuf::from("/project");
586        let mut results = AnalysisResults::default();
587        results.type_only_dependencies.push(TypeOnlyDependency {
588            package_name: "zod".to_string(),
589            path: root.join("package.json"),
590            line: 8,
591        });
592
593        let lines = build_compact_lines(&results, &root);
594        assert_eq!(lines[0], "type-only-dep:zod");
595    }
596
597    // ── Multiple items of same type ──
598
599    #[test]
600    fn compact_multiple_unused_files() {
601        let root = PathBuf::from("/project");
602        let mut results = AnalysisResults::default();
603        results.unused_files.push(UnusedFile {
604            path: root.join("src/a.ts"),
605        });
606        results.unused_files.push(UnusedFile {
607            path: root.join("src/b.ts"),
608        });
609
610        let lines = build_compact_lines(&results, &root);
611        assert_eq!(lines.len(), 2);
612        assert_eq!(lines[0], "unused-file:src/a.ts");
613        assert_eq!(lines[1], "unused-file:src/b.ts");
614    }
615
616    // ── Output ordering matches issue types ──
617
618    #[test]
619    fn compact_ordering_optional_dep_between_devdep_and_enum() {
620        let root = PathBuf::from("/project");
621        let mut results = AnalysisResults::default();
622        results.unused_dev_dependencies.push(UnusedDependency {
623            package_name: "jest".to_string(),
624            location: DependencyLocation::DevDependencies,
625            path: root.join("package.json"),
626            line: 5,
627        });
628        results.unused_optional_dependencies.push(UnusedDependency {
629            package_name: "fsevents".to_string(),
630            location: DependencyLocation::OptionalDependencies,
631            path: root.join("package.json"),
632            line: 12,
633        });
634        results.unused_enum_members.push(UnusedMember {
635            path: root.join("src/enums.ts"),
636            parent_name: "Status".to_string(),
637            member_name: "Deprecated".to_string(),
638            kind: MemberKind::EnumMember,
639            line: 8,
640            col: 2,
641        });
642
643        let lines = build_compact_lines(&results, &root);
644        assert_eq!(lines.len(), 3);
645        assert!(lines[0].starts_with("unused-devdep:"));
646        assert!(lines[1].starts_with("unused-optionaldep:"));
647        assert!(lines[2].starts_with("unused-enum-member:"));
648    }
649
650    // ── Path outside root ──
651
652    #[test]
653    fn compact_path_outside_root_preserved() {
654        let root = PathBuf::from("/project");
655        let mut results = AnalysisResults::default();
656        results.unused_files.push(UnusedFile {
657            path: PathBuf::from("/other/place/file.ts"),
658        });
659
660        let lines = build_compact_lines(&results, &root);
661        assert!(lines[0].contains("/other/place/file.ts"));
662    }
663}