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