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
14pub 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.label();
166 let effort = target.effort.label();
167 println!(
168 "refactoring-target:{}:priority={:.1},category={},effort={}:{}",
169 relative, target.priority, category, effort, target.recommendation,
170 );
171 }
172}
173
174pub(super) fn print_duplication_compact(report: &DuplicationReport, root: &Path) {
175 for (i, group) in report.clone_groups.iter().enumerate() {
176 for instance in &group.instances {
177 let relative =
178 normalize_uri(&relative_path(&instance.file, root).display().to_string());
179 println!(
180 "clone-group-{}:{}:{}-{}:{}tokens",
181 i + 1,
182 relative,
183 instance.start_line,
184 instance.end_line,
185 group.token_count
186 );
187 }
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194 use fallow_core::extract::MemberKind;
195 use fallow_core::results::*;
196 use std::path::PathBuf;
197
198 fn sample_results(root: &Path) -> AnalysisResults {
200 let mut r = AnalysisResults::default();
201
202 r.unused_files.push(UnusedFile {
203 path: root.join("src/dead.ts"),
204 });
205 r.unused_exports.push(UnusedExport {
206 path: root.join("src/utils.ts"),
207 export_name: "helperFn".to_string(),
208 is_type_only: false,
209 line: 10,
210 col: 4,
211 span_start: 120,
212 is_re_export: false,
213 });
214 r.unused_types.push(UnusedExport {
215 path: root.join("src/types.ts"),
216 export_name: "OldType".to_string(),
217 is_type_only: true,
218 line: 5,
219 col: 0,
220 span_start: 60,
221 is_re_export: false,
222 });
223 r.unused_dependencies.push(UnusedDependency {
224 package_name: "lodash".to_string(),
225 location: DependencyLocation::Dependencies,
226 path: root.join("package.json"),
227 line: 5,
228 });
229 r.unused_dev_dependencies.push(UnusedDependency {
230 package_name: "jest".to_string(),
231 location: DependencyLocation::DevDependencies,
232 path: root.join("package.json"),
233 line: 5,
234 });
235 r.unused_enum_members.push(UnusedMember {
236 path: root.join("src/enums.ts"),
237 parent_name: "Status".to_string(),
238 member_name: "Deprecated".to_string(),
239 kind: MemberKind::EnumMember,
240 line: 8,
241 col: 2,
242 });
243 r.unused_class_members.push(UnusedMember {
244 path: root.join("src/service.ts"),
245 parent_name: "UserService".to_string(),
246 member_name: "legacyMethod".to_string(),
247 kind: MemberKind::ClassMethod,
248 line: 42,
249 col: 4,
250 });
251 r.unresolved_imports.push(UnresolvedImport {
252 path: root.join("src/app.ts"),
253 specifier: "./missing-module".to_string(),
254 line: 3,
255 col: 0,
256 specifier_col: 0,
257 });
258 r.unlisted_dependencies.push(UnlistedDependency {
259 package_name: "chalk".to_string(),
260 imported_from: vec![ImportSite {
261 path: root.join("src/cli.ts"),
262 line: 2,
263 col: 0,
264 }],
265 });
266 r.duplicate_exports.push(DuplicateExport {
267 export_name: "Config".to_string(),
268 locations: vec![
269 DuplicateLocation {
270 path: root.join("src/config.ts"),
271 line: 15,
272 col: 0,
273 },
274 DuplicateLocation {
275 path: root.join("src/types.ts"),
276 line: 30,
277 col: 0,
278 },
279 ],
280 });
281 r.type_only_dependencies.push(TypeOnlyDependency {
282 package_name: "zod".to_string(),
283 path: root.join("package.json"),
284 line: 8,
285 });
286
287 r
288 }
289
290 #[test]
291 fn compact_empty_results_no_lines() {
292 let root = PathBuf::from("/project");
293 let results = AnalysisResults::default();
294 let lines = build_compact_lines(&results, &root);
295 assert!(lines.is_empty());
296 }
297
298 #[test]
299 fn compact_unused_file_format() {
300 let root = PathBuf::from("/project");
301 let mut results = AnalysisResults::default();
302 results.unused_files.push(UnusedFile {
303 path: root.join("src/dead.ts"),
304 });
305
306 let lines = build_compact_lines(&results, &root);
307 assert_eq!(lines.len(), 1);
308 assert_eq!(lines[0], "unused-file:src/dead.ts");
309 }
310
311 #[test]
312 fn compact_unused_export_format() {
313 let root = PathBuf::from("/project");
314 let mut results = AnalysisResults::default();
315 results.unused_exports.push(UnusedExport {
316 path: root.join("src/utils.ts"),
317 export_name: "helperFn".to_string(),
318 is_type_only: false,
319 line: 10,
320 col: 4,
321 span_start: 120,
322 is_re_export: false,
323 });
324
325 let lines = build_compact_lines(&results, &root);
326 assert_eq!(lines[0], "unused-export:src/utils.ts:10:helperFn");
327 }
328
329 #[test]
330 fn compact_unused_type_format() {
331 let root = PathBuf::from("/project");
332 let mut results = AnalysisResults::default();
333 results.unused_types.push(UnusedExport {
334 path: root.join("src/types.ts"),
335 export_name: "OldType".to_string(),
336 is_type_only: true,
337 line: 5,
338 col: 0,
339 span_start: 60,
340 is_re_export: false,
341 });
342
343 let lines = build_compact_lines(&results, &root);
344 assert_eq!(lines[0], "unused-type:src/types.ts:5:OldType");
345 }
346
347 #[test]
348 fn compact_unused_dep_format() {
349 let root = PathBuf::from("/project");
350 let mut results = AnalysisResults::default();
351 results.unused_dependencies.push(UnusedDependency {
352 package_name: "lodash".to_string(),
353 location: DependencyLocation::Dependencies,
354 path: root.join("package.json"),
355 line: 5,
356 });
357
358 let lines = build_compact_lines(&results, &root);
359 assert_eq!(lines[0], "unused-dep:lodash");
360 }
361
362 #[test]
363 fn compact_unused_devdep_format() {
364 let root = PathBuf::from("/project");
365 let mut results = AnalysisResults::default();
366 results.unused_dev_dependencies.push(UnusedDependency {
367 package_name: "jest".to_string(),
368 location: DependencyLocation::DevDependencies,
369 path: root.join("package.json"),
370 line: 5,
371 });
372
373 let lines = build_compact_lines(&results, &root);
374 assert_eq!(lines[0], "unused-devdep:jest");
375 }
376
377 #[test]
378 fn compact_unused_enum_member_format() {
379 let root = PathBuf::from("/project");
380 let mut results = AnalysisResults::default();
381 results.unused_enum_members.push(UnusedMember {
382 path: root.join("src/enums.ts"),
383 parent_name: "Status".to_string(),
384 member_name: "Deprecated".to_string(),
385 kind: MemberKind::EnumMember,
386 line: 8,
387 col: 2,
388 });
389
390 let lines = build_compact_lines(&results, &root);
391 assert_eq!(
392 lines[0],
393 "unused-enum-member:src/enums.ts:8:Status.Deprecated"
394 );
395 }
396
397 #[test]
398 fn compact_unused_class_member_format() {
399 let root = PathBuf::from("/project");
400 let mut results = AnalysisResults::default();
401 results.unused_class_members.push(UnusedMember {
402 path: root.join("src/service.ts"),
403 parent_name: "UserService".to_string(),
404 member_name: "legacyMethod".to_string(),
405 kind: MemberKind::ClassMethod,
406 line: 42,
407 col: 4,
408 });
409
410 let lines = build_compact_lines(&results, &root);
411 assert_eq!(
412 lines[0],
413 "unused-class-member:src/service.ts:42:UserService.legacyMethod"
414 );
415 }
416
417 #[test]
418 fn compact_unresolved_import_format() {
419 let root = PathBuf::from("/project");
420 let mut results = AnalysisResults::default();
421 results.unresolved_imports.push(UnresolvedImport {
422 path: root.join("src/app.ts"),
423 specifier: "./missing-module".to_string(),
424 line: 3,
425 col: 0,
426 specifier_col: 0,
427 });
428
429 let lines = build_compact_lines(&results, &root);
430 assert_eq!(lines[0], "unresolved-import:src/app.ts:3:./missing-module");
431 }
432
433 #[test]
434 fn compact_unlisted_dep_format() {
435 let root = PathBuf::from("/project");
436 let mut results = AnalysisResults::default();
437 results.unlisted_dependencies.push(UnlistedDependency {
438 package_name: "chalk".to_string(),
439 imported_from: vec![],
440 });
441
442 let lines = build_compact_lines(&results, &root);
443 assert_eq!(lines[0], "unlisted-dep:chalk");
444 }
445
446 #[test]
447 fn compact_duplicate_export_format() {
448 let root = PathBuf::from("/project");
449 let mut results = AnalysisResults::default();
450 results.duplicate_exports.push(DuplicateExport {
451 export_name: "Config".to_string(),
452 locations: vec![
453 DuplicateLocation {
454 path: root.join("src/a.ts"),
455 line: 15,
456 col: 0,
457 },
458 DuplicateLocation {
459 path: root.join("src/b.ts"),
460 line: 30,
461 col: 0,
462 },
463 ],
464 });
465
466 let lines = build_compact_lines(&results, &root);
467 assert_eq!(lines[0], "duplicate-export:Config");
468 }
469
470 #[test]
471 fn compact_all_issue_types_produce_lines() {
472 let root = PathBuf::from("/project");
473 let results = sample_results(&root);
474 let lines = build_compact_lines(&results, &root);
475
476 assert_eq!(lines.len(), 11);
478
479 assert!(lines[0].starts_with("unused-file:"));
481 assert!(lines[1].starts_with("unused-export:"));
482 assert!(lines[2].starts_with("unused-type:"));
483 assert!(lines[3].starts_with("unused-dep:"));
484 assert!(lines[4].starts_with("unused-devdep:"));
485 assert!(lines[5].starts_with("unused-enum-member:"));
486 assert!(lines[6].starts_with("unused-class-member:"));
487 assert!(lines[7].starts_with("unresolved-import:"));
488 assert!(lines[8].starts_with("unlisted-dep:"));
489 assert!(lines[9].starts_with("duplicate-export:"));
490 assert!(lines[10].starts_with("type-only-dep:"));
491 }
492
493 #[test]
494 fn compact_strips_root_prefix_from_paths() {
495 let root = PathBuf::from("/project");
496 let mut results = AnalysisResults::default();
497 results.unused_files.push(UnusedFile {
498 path: PathBuf::from("/project/src/deep/nested/file.ts"),
499 });
500
501 let lines = build_compact_lines(&results, &root);
502 assert_eq!(lines[0], "unused-file:src/deep/nested/file.ts");
503 }
504}