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