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