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