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 cycle in &results.circular_dependencies {
86        let chain: Vec<String> = cycle.files.iter().map(|p| rel(p)).collect();
87        let mut display_chain = chain.clone();
88        if let Some(first) = chain.first() {
89            display_chain.push(first.clone());
90        }
91        let first_file = chain.first().map_or_else(String::new, Clone::clone);
92        lines.push(format!(
93            "circular-dependency:{}:0:{}",
94            first_file,
95            display_chain.join(" \u{2192} ")
96        ));
97    }
98
99    lines
100}
101
102pub(super) fn print_duplication_compact(report: &DuplicationReport, root: &Path) {
103    for (i, group) in report.clone_groups.iter().enumerate() {
104        for instance in &group.instances {
105            let relative =
106                normalize_uri(&relative_path(&instance.file, root).display().to_string());
107            println!(
108                "clone-group-{}:{}:{}-{}:{}tokens",
109                i + 1,
110                relative,
111                instance.start_line,
112                instance.end_line,
113                group.token_count
114            );
115        }
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use fallow_core::extract::MemberKind;
123    use fallow_core::results::*;
124    use std::path::PathBuf;
125
126    /// Helper: build an `AnalysisResults` populated with one issue of every type.
127    fn sample_results(root: &Path) -> AnalysisResults {
128        let mut r = AnalysisResults::default();
129
130        r.unused_files.push(UnusedFile {
131            path: root.join("src/dead.ts"),
132        });
133        r.unused_exports.push(UnusedExport {
134            path: root.join("src/utils.ts"),
135            export_name: "helperFn".to_string(),
136            is_type_only: false,
137            line: 10,
138            col: 4,
139            span_start: 120,
140            is_re_export: false,
141        });
142        r.unused_types.push(UnusedExport {
143            path: root.join("src/types.ts"),
144            export_name: "OldType".to_string(),
145            is_type_only: true,
146            line: 5,
147            col: 0,
148            span_start: 60,
149            is_re_export: false,
150        });
151        r.unused_dependencies.push(UnusedDependency {
152            package_name: "lodash".to_string(),
153            location: DependencyLocation::Dependencies,
154            path: root.join("package.json"),
155        });
156        r.unused_dev_dependencies.push(UnusedDependency {
157            package_name: "jest".to_string(),
158            location: DependencyLocation::DevDependencies,
159            path: root.join("package.json"),
160        });
161        r.unused_enum_members.push(UnusedMember {
162            path: root.join("src/enums.ts"),
163            parent_name: "Status".to_string(),
164            member_name: "Deprecated".to_string(),
165            kind: MemberKind::EnumMember,
166            line: 8,
167            col: 2,
168        });
169        r.unused_class_members.push(UnusedMember {
170            path: root.join("src/service.ts"),
171            parent_name: "UserService".to_string(),
172            member_name: "legacyMethod".to_string(),
173            kind: MemberKind::ClassMethod,
174            line: 42,
175            col: 4,
176        });
177        r.unresolved_imports.push(UnresolvedImport {
178            path: root.join("src/app.ts"),
179            specifier: "./missing-module".to_string(),
180            line: 3,
181            col: 0,
182        });
183        r.unlisted_dependencies.push(UnlistedDependency {
184            package_name: "chalk".to_string(),
185            imported_from: vec![root.join("src/cli.ts")],
186        });
187        r.duplicate_exports.push(DuplicateExport {
188            export_name: "Config".to_string(),
189            locations: vec![root.join("src/config.ts"), root.join("src/types.ts")],
190        });
191
192        r
193    }
194
195    #[test]
196    fn compact_empty_results_no_lines() {
197        let root = PathBuf::from("/project");
198        let results = AnalysisResults::default();
199        let lines = build_compact_lines(&results, &root);
200        assert!(lines.is_empty());
201    }
202
203    #[test]
204    fn compact_unused_file_format() {
205        let root = PathBuf::from("/project");
206        let mut results = AnalysisResults::default();
207        results.unused_files.push(UnusedFile {
208            path: root.join("src/dead.ts"),
209        });
210
211        let lines = build_compact_lines(&results, &root);
212        assert_eq!(lines.len(), 1);
213        assert_eq!(lines[0], "unused-file:src/dead.ts");
214    }
215
216    #[test]
217    fn compact_unused_export_format() {
218        let root = PathBuf::from("/project");
219        let mut results = AnalysisResults::default();
220        results.unused_exports.push(UnusedExport {
221            path: root.join("src/utils.ts"),
222            export_name: "helperFn".to_string(),
223            is_type_only: false,
224            line: 10,
225            col: 4,
226            span_start: 120,
227            is_re_export: false,
228        });
229
230        let lines = build_compact_lines(&results, &root);
231        assert_eq!(lines[0], "unused-export:src/utils.ts:10:helperFn");
232    }
233
234    #[test]
235    fn compact_unused_type_format() {
236        let root = PathBuf::from("/project");
237        let mut results = AnalysisResults::default();
238        results.unused_types.push(UnusedExport {
239            path: root.join("src/types.ts"),
240            export_name: "OldType".to_string(),
241            is_type_only: true,
242            line: 5,
243            col: 0,
244            span_start: 60,
245            is_re_export: false,
246        });
247
248        let lines = build_compact_lines(&results, &root);
249        assert_eq!(lines[0], "unused-type:src/types.ts:5:OldType");
250    }
251
252    #[test]
253    fn compact_unused_dep_format() {
254        let root = PathBuf::from("/project");
255        let mut results = AnalysisResults::default();
256        results.unused_dependencies.push(UnusedDependency {
257            package_name: "lodash".to_string(),
258            location: DependencyLocation::Dependencies,
259            path: root.join("package.json"),
260        });
261
262        let lines = build_compact_lines(&results, &root);
263        assert_eq!(lines[0], "unused-dep:lodash");
264    }
265
266    #[test]
267    fn compact_unused_devdep_format() {
268        let root = PathBuf::from("/project");
269        let mut results = AnalysisResults::default();
270        results.unused_dev_dependencies.push(UnusedDependency {
271            package_name: "jest".to_string(),
272            location: DependencyLocation::DevDependencies,
273            path: root.join("package.json"),
274        });
275
276        let lines = build_compact_lines(&results, &root);
277        assert_eq!(lines[0], "unused-devdep:jest");
278    }
279
280    #[test]
281    fn compact_unused_enum_member_format() {
282        let root = PathBuf::from("/project");
283        let mut results = AnalysisResults::default();
284        results.unused_enum_members.push(UnusedMember {
285            path: root.join("src/enums.ts"),
286            parent_name: "Status".to_string(),
287            member_name: "Deprecated".to_string(),
288            kind: MemberKind::EnumMember,
289            line: 8,
290            col: 2,
291        });
292
293        let lines = build_compact_lines(&results, &root);
294        assert_eq!(
295            lines[0],
296            "unused-enum-member:src/enums.ts:8:Status.Deprecated"
297        );
298    }
299
300    #[test]
301    fn compact_unused_class_member_format() {
302        let root = PathBuf::from("/project");
303        let mut results = AnalysisResults::default();
304        results.unused_class_members.push(UnusedMember {
305            path: root.join("src/service.ts"),
306            parent_name: "UserService".to_string(),
307            member_name: "legacyMethod".to_string(),
308            kind: MemberKind::ClassMethod,
309            line: 42,
310            col: 4,
311        });
312
313        let lines = build_compact_lines(&results, &root);
314        assert_eq!(
315            lines[0],
316            "unused-class-member:src/service.ts:42:UserService.legacyMethod"
317        );
318    }
319
320    #[test]
321    fn compact_unresolved_import_format() {
322        let root = PathBuf::from("/project");
323        let mut results = AnalysisResults::default();
324        results.unresolved_imports.push(UnresolvedImport {
325            path: root.join("src/app.ts"),
326            specifier: "./missing-module".to_string(),
327            line: 3,
328            col: 0,
329        });
330
331        let lines = build_compact_lines(&results, &root);
332        assert_eq!(lines[0], "unresolved-import:src/app.ts:3:./missing-module");
333    }
334
335    #[test]
336    fn compact_unlisted_dep_format() {
337        let root = PathBuf::from("/project");
338        let mut results = AnalysisResults::default();
339        results.unlisted_dependencies.push(UnlistedDependency {
340            package_name: "chalk".to_string(),
341            imported_from: vec![],
342        });
343
344        let lines = build_compact_lines(&results, &root);
345        assert_eq!(lines[0], "unlisted-dep:chalk");
346    }
347
348    #[test]
349    fn compact_duplicate_export_format() {
350        let root = PathBuf::from("/project");
351        let mut results = AnalysisResults::default();
352        results.duplicate_exports.push(DuplicateExport {
353            export_name: "Config".to_string(),
354            locations: vec![root.join("src/a.ts"), root.join("src/b.ts")],
355        });
356
357        let lines = build_compact_lines(&results, &root);
358        assert_eq!(lines[0], "duplicate-export:Config");
359    }
360
361    #[test]
362    fn compact_all_issue_types_produce_lines() {
363        let root = PathBuf::from("/project");
364        let results = sample_results(&root);
365        let lines = build_compact_lines(&results, &root);
366
367        // 10 issue types, one of each
368        assert_eq!(lines.len(), 10);
369
370        // Verify ordering: unused_files first, duplicate_exports last
371        assert!(lines[0].starts_with("unused-file:"));
372        assert!(lines[1].starts_with("unused-export:"));
373        assert!(lines[2].starts_with("unused-type:"));
374        assert!(lines[3].starts_with("unused-dep:"));
375        assert!(lines[4].starts_with("unused-devdep:"));
376        assert!(lines[5].starts_with("unused-enum-member:"));
377        assert!(lines[6].starts_with("unused-class-member:"));
378        assert!(lines[7].starts_with("unresolved-import:"));
379        assert!(lines[8].starts_with("unlisted-dep:"));
380        assert!(lines[9].starts_with("duplicate-export:"));
381    }
382
383    #[test]
384    fn compact_strips_root_prefix_from_paths() {
385        let root = PathBuf::from("/project");
386        let mut results = AnalysisResults::default();
387        results.unused_files.push(UnusedFile {
388            path: PathBuf::from("/project/src/deep/nested/file.ts"),
389        });
390
391        let lines = build_compact_lines(&results, &root);
392        assert_eq!(lines[0], "unused-file:src/deep/nested/file.ts");
393    }
394}