1use std::path::Path;
2
3use fallow_core::duplicates::DuplicationReport;
4use fallow_core::results::{AnalysisResults, UnusedExport, UnusedMember};
5
6use super::grouping::ResultGroup;
7use super::{normalize_uri, relative_path};
8
9pub(super) fn print_compact(results: &AnalysisResults, root: &Path) {
10 for line in build_compact_lines(results, root) {
11 println!("{line}");
12 }
13}
14
15pub fn build_compact_lines(results: &AnalysisResults, root: &Path) -> Vec<String> {
18 let rel = |p: &Path| normalize_uri(&relative_path(p, root).display().to_string());
19
20 let compact_export = |export: &UnusedExport, kind: &str, re_kind: &str| -> String {
21 let tag = if export.is_re_export { re_kind } else { kind };
22 format!(
23 "{}:{}:{}:{}",
24 tag,
25 rel(&export.path),
26 export.line,
27 export.export_name
28 )
29 };
30
31 let compact_member = |member: &UnusedMember, kind: &str| -> String {
32 format!(
33 "{}:{}:{}:{}.{}",
34 kind,
35 rel(&member.path),
36 member.line,
37 member.parent_name,
38 member.member_name
39 )
40 };
41
42 let mut lines = Vec::new();
43
44 for file in &results.unused_files {
45 lines.push(format!("unused-file:{}", rel(&file.path)));
46 }
47 for export in &results.unused_exports {
48 lines.push(compact_export(export, "unused-export", "unused-re-export"));
49 }
50 for export in &results.unused_types {
51 lines.push(compact_export(
52 export,
53 "unused-type",
54 "unused-re-export-type",
55 ));
56 }
57 for dep in &results.unused_dependencies {
58 lines.push(format!("unused-dep:{}", dep.package_name));
59 }
60 for dep in &results.unused_dev_dependencies {
61 lines.push(format!("unused-devdep:{}", dep.package_name));
62 }
63 for dep in &results.unused_optional_dependencies {
64 lines.push(format!("unused-optionaldep:{}", dep.package_name));
65 }
66 for member in &results.unused_enum_members {
67 lines.push(compact_member(member, "unused-enum-member"));
68 }
69 for member in &results.unused_class_members {
70 lines.push(compact_member(member, "unused-class-member"));
71 }
72 for import in &results.unresolved_imports {
73 lines.push(format!(
74 "unresolved-import:{}:{}:{}",
75 rel(&import.path),
76 import.line,
77 import.specifier
78 ));
79 }
80 for dep in &results.unlisted_dependencies {
81 lines.push(format!("unlisted-dep:{}", dep.package_name));
82 }
83 for dup in &results.duplicate_exports {
84 lines.push(format!("duplicate-export:{}", dup.export_name));
85 }
86 for dep in &results.type_only_dependencies {
87 lines.push(format!("type-only-dep:{}", dep.package_name));
88 }
89 for dep in &results.test_only_dependencies {
90 lines.push(format!("test-only-dep:{}", dep.package_name));
91 }
92 for cycle in &results.circular_dependencies {
93 let chain: Vec<String> = cycle.files.iter().map(|p| rel(p)).collect();
94 let mut display_chain = chain.clone();
95 if let Some(first) = chain.first() {
96 display_chain.push(first.clone());
97 }
98 let first_file = chain.first().map_or_else(String::new, Clone::clone);
99 let cross_pkg_tag = if cycle.is_cross_package {
100 " (cross-package)"
101 } else {
102 ""
103 };
104 lines.push(format!(
105 "circular-dependency:{}:{}:{}{}",
106 first_file,
107 cycle.line,
108 display_chain.join(" \u{2192} "),
109 cross_pkg_tag
110 ));
111 }
112 for v in &results.boundary_violations {
113 lines.push(format!(
114 "boundary-violation:{}:{}:{} -> {} ({} -> {})",
115 rel(&v.from_path),
116 v.line,
117 rel(&v.from_path),
118 rel(&v.to_path),
119 v.from_zone,
120 v.to_zone,
121 ));
122 }
123
124 lines
125}
126
127pub(super) fn print_grouped_compact(groups: &[ResultGroup], root: &Path) {
131 for group in groups {
132 for line in build_compact_lines(&group.results, root) {
133 println!("{}\t{line}", group.key);
134 }
135 }
136}
137
138pub(super) fn print_health_compact(report: &crate::health_types::HealthReport, root: &Path) {
139 if let Some(ref hs) = report.health_score {
140 println!("health-score:{:.1}:{}", hs.score, hs.grade);
141 }
142 if let Some(ref vs) = report.vital_signs {
143 let mut parts = Vec::new();
144 parts.push(format!("avg_cyclomatic={:.1}", vs.avg_cyclomatic));
145 parts.push(format!("p90_cyclomatic={}", vs.p90_cyclomatic));
146 if let Some(v) = vs.dead_file_pct {
147 parts.push(format!("dead_file_pct={v:.1}"));
148 }
149 if let Some(v) = vs.dead_export_pct {
150 parts.push(format!("dead_export_pct={v:.1}"));
151 }
152 if let Some(v) = vs.maintainability_avg {
153 parts.push(format!("maintainability_avg={v:.1}"));
154 }
155 if let Some(v) = vs.hotspot_count {
156 parts.push(format!("hotspot_count={v}"));
157 }
158 if let Some(v) = vs.circular_dep_count {
159 parts.push(format!("circular_dep_count={v}"));
160 }
161 if let Some(v) = vs.unused_dep_count {
162 parts.push(format!("unused_dep_count={v}"));
163 }
164 println!("vital-signs:{}", parts.join(","));
165 }
166 for finding in &report.findings {
167 let relative = normalize_uri(&relative_path(&finding.path, root).display().to_string());
168 println!(
169 "high-complexity:{}:{}:{}:cyclomatic={},cognitive={}",
170 relative, finding.line, finding.name, finding.cyclomatic, finding.cognitive,
171 );
172 }
173 for score in &report.file_scores {
174 let relative = normalize_uri(&relative_path(&score.path, root).display().to_string());
175 println!(
176 "file-score:{}:mi={:.1},fan_in={},fan_out={},dead={:.2},density={:.2}",
177 relative,
178 score.maintainability_index,
179 score.fan_in,
180 score.fan_out,
181 score.dead_code_ratio,
182 score.complexity_density,
183 );
184 }
185 for entry in &report.hotspots {
186 let relative = normalize_uri(&relative_path(&entry.path, root).display().to_string());
187 println!(
188 "hotspot:{}:score={:.1},commits={},churn={},density={:.2},fan_in={},trend={}",
189 relative,
190 entry.score,
191 entry.commits,
192 entry.lines_added + entry.lines_deleted,
193 entry.complexity_density,
194 entry.fan_in,
195 entry.trend,
196 );
197 }
198 if let Some(ref trend) = report.health_trend {
199 println!(
200 "trend:overall:direction={}",
201 trend.overall_direction.label()
202 );
203 for m in &trend.metrics {
204 println!(
205 "trend:{}:previous={:.1},current={:.1},delta={:+.1},direction={}",
206 m.name,
207 m.previous,
208 m.current,
209 m.delta,
210 m.direction.label(),
211 );
212 }
213 }
214 for target in &report.targets {
215 let relative = normalize_uri(&relative_path(&target.path, root).display().to_string());
216 let category = target.category.compact_label();
217 let effort = target.effort.label();
218 let confidence = target.confidence.label();
219 println!(
220 "refactoring-target:{}:priority={:.1},efficiency={:.1},category={},effort={},confidence={}:{}",
221 relative,
222 target.priority,
223 target.efficiency,
224 category,
225 effort,
226 confidence,
227 target.recommendation,
228 );
229 }
230}
231
232pub(super) fn print_duplication_compact(report: &DuplicationReport, root: &Path) {
233 for (i, group) in report.clone_groups.iter().enumerate() {
234 for instance in &group.instances {
235 let relative =
236 normalize_uri(&relative_path(&instance.file, root).display().to_string());
237 println!(
238 "clone-group-{}:{}:{}-{}:{}tokens",
239 i + 1,
240 relative,
241 instance.start_line,
242 instance.end_line,
243 group.token_count
244 );
245 }
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use crate::report::test_helpers::sample_results;
253 use fallow_core::extract::MemberKind;
254 use fallow_core::results::*;
255 use std::path::PathBuf;
256
257 #[test]
258 fn compact_empty_results_no_lines() {
259 let root = PathBuf::from("/project");
260 let results = AnalysisResults::default();
261 let lines = build_compact_lines(&results, &root);
262 assert!(lines.is_empty());
263 }
264
265 #[test]
266 fn compact_unused_file_format() {
267 let root = PathBuf::from("/project");
268 let mut results = AnalysisResults::default();
269 results.unused_files.push(UnusedFile {
270 path: root.join("src/dead.ts"),
271 });
272
273 let lines = build_compact_lines(&results, &root);
274 assert_eq!(lines.len(), 1);
275 assert_eq!(lines[0], "unused-file:src/dead.ts");
276 }
277
278 #[test]
279 fn compact_unused_export_format() {
280 let root = PathBuf::from("/project");
281 let mut results = AnalysisResults::default();
282 results.unused_exports.push(UnusedExport {
283 path: root.join("src/utils.ts"),
284 export_name: "helperFn".to_string(),
285 is_type_only: false,
286 line: 10,
287 col: 4,
288 span_start: 120,
289 is_re_export: false,
290 });
291
292 let lines = build_compact_lines(&results, &root);
293 assert_eq!(lines[0], "unused-export:src/utils.ts:10:helperFn");
294 }
295
296 #[test]
297 fn compact_unused_type_format() {
298 let root = PathBuf::from("/project");
299 let mut results = AnalysisResults::default();
300 results.unused_types.push(UnusedExport {
301 path: root.join("src/types.ts"),
302 export_name: "OldType".to_string(),
303 is_type_only: true,
304 line: 5,
305 col: 0,
306 span_start: 60,
307 is_re_export: false,
308 });
309
310 let lines = build_compact_lines(&results, &root);
311 assert_eq!(lines[0], "unused-type:src/types.ts:5:OldType");
312 }
313
314 #[test]
315 fn compact_unused_dep_format() {
316 let root = PathBuf::from("/project");
317 let mut results = AnalysisResults::default();
318 results.unused_dependencies.push(UnusedDependency {
319 package_name: "lodash".to_string(),
320 location: DependencyLocation::Dependencies,
321 path: root.join("package.json"),
322 line: 5,
323 });
324
325 let lines = build_compact_lines(&results, &root);
326 assert_eq!(lines[0], "unused-dep:lodash");
327 }
328
329 #[test]
330 fn compact_unused_devdep_format() {
331 let root = PathBuf::from("/project");
332 let mut results = AnalysisResults::default();
333 results.unused_dev_dependencies.push(UnusedDependency {
334 package_name: "jest".to_string(),
335 location: DependencyLocation::DevDependencies,
336 path: root.join("package.json"),
337 line: 5,
338 });
339
340 let lines = build_compact_lines(&results, &root);
341 assert_eq!(lines[0], "unused-devdep:jest");
342 }
343
344 #[test]
345 fn compact_unused_enum_member_format() {
346 let root = PathBuf::from("/project");
347 let mut results = AnalysisResults::default();
348 results.unused_enum_members.push(UnusedMember {
349 path: root.join("src/enums.ts"),
350 parent_name: "Status".to_string(),
351 member_name: "Deprecated".to_string(),
352 kind: MemberKind::EnumMember,
353 line: 8,
354 col: 2,
355 });
356
357 let lines = build_compact_lines(&results, &root);
358 assert_eq!(
359 lines[0],
360 "unused-enum-member:src/enums.ts:8:Status.Deprecated"
361 );
362 }
363
364 #[test]
365 fn compact_unused_class_member_format() {
366 let root = PathBuf::from("/project");
367 let mut results = AnalysisResults::default();
368 results.unused_class_members.push(UnusedMember {
369 path: root.join("src/service.ts"),
370 parent_name: "UserService".to_string(),
371 member_name: "legacyMethod".to_string(),
372 kind: MemberKind::ClassMethod,
373 line: 42,
374 col: 4,
375 });
376
377 let lines = build_compact_lines(&results, &root);
378 assert_eq!(
379 lines[0],
380 "unused-class-member:src/service.ts:42:UserService.legacyMethod"
381 );
382 }
383
384 #[test]
385 fn compact_unresolved_import_format() {
386 let root = PathBuf::from("/project");
387 let mut results = AnalysisResults::default();
388 results.unresolved_imports.push(UnresolvedImport {
389 path: root.join("src/app.ts"),
390 specifier: "./missing-module".to_string(),
391 line: 3,
392 col: 0,
393 specifier_col: 0,
394 });
395
396 let lines = build_compact_lines(&results, &root);
397 assert_eq!(lines[0], "unresolved-import:src/app.ts:3:./missing-module");
398 }
399
400 #[test]
401 fn compact_unlisted_dep_format() {
402 let root = PathBuf::from("/project");
403 let mut results = AnalysisResults::default();
404 results.unlisted_dependencies.push(UnlistedDependency {
405 package_name: "chalk".to_string(),
406 imported_from: vec![],
407 });
408
409 let lines = build_compact_lines(&results, &root);
410 assert_eq!(lines[0], "unlisted-dep:chalk");
411 }
412
413 #[test]
414 fn compact_duplicate_export_format() {
415 let root = PathBuf::from("/project");
416 let mut results = AnalysisResults::default();
417 results.duplicate_exports.push(DuplicateExport {
418 export_name: "Config".to_string(),
419 locations: vec![
420 DuplicateLocation {
421 path: root.join("src/a.ts"),
422 line: 15,
423 col: 0,
424 },
425 DuplicateLocation {
426 path: root.join("src/b.ts"),
427 line: 30,
428 col: 0,
429 },
430 ],
431 });
432
433 let lines = build_compact_lines(&results, &root);
434 assert_eq!(lines[0], "duplicate-export:Config");
435 }
436
437 #[test]
438 fn compact_all_issue_types_produce_lines() {
439 let root = PathBuf::from("/project");
440 let results = sample_results(&root);
441 let lines = build_compact_lines(&results, &root);
442
443 assert_eq!(lines.len(), 15);
445
446 assert!(lines[0].starts_with("unused-file:"));
448 assert!(lines[1].starts_with("unused-export:"));
449 assert!(lines[2].starts_with("unused-type:"));
450 assert!(lines[3].starts_with("unused-dep:"));
451 assert!(lines[4].starts_with("unused-devdep:"));
452 assert!(lines[5].starts_with("unused-optionaldep:"));
453 assert!(lines[6].starts_with("unused-enum-member:"));
454 assert!(lines[7].starts_with("unused-class-member:"));
455 assert!(lines[8].starts_with("unresolved-import:"));
456 assert!(lines[9].starts_with("unlisted-dep:"));
457 assert!(lines[10].starts_with("duplicate-export:"));
458 assert!(lines[11].starts_with("type-only-dep:"));
459 assert!(lines[12].starts_with("test-only-dep:"));
460 assert!(lines[13].starts_with("circular-dependency:"));
461 assert!(lines[14].starts_with("boundary-violation:"));
462 }
463
464 #[test]
465 fn compact_strips_root_prefix_from_paths() {
466 let root = PathBuf::from("/project");
467 let mut results = AnalysisResults::default();
468 results.unused_files.push(UnusedFile {
469 path: PathBuf::from("/project/src/deep/nested/file.ts"),
470 });
471
472 let lines = build_compact_lines(&results, &root);
473 assert_eq!(lines[0], "unused-file:src/deep/nested/file.ts");
474 }
475
476 #[test]
479 fn compact_re_export_tagged_correctly() {
480 let root = PathBuf::from("/project");
481 let mut results = AnalysisResults::default();
482 results.unused_exports.push(UnusedExport {
483 path: root.join("src/index.ts"),
484 export_name: "reExported".to_string(),
485 is_type_only: false,
486 line: 1,
487 col: 0,
488 span_start: 0,
489 is_re_export: true,
490 });
491
492 let lines = build_compact_lines(&results, &root);
493 assert_eq!(lines[0], "unused-re-export:src/index.ts:1:reExported");
494 }
495
496 #[test]
497 fn compact_type_re_export_tagged_correctly() {
498 let root = PathBuf::from("/project");
499 let mut results = AnalysisResults::default();
500 results.unused_types.push(UnusedExport {
501 path: root.join("src/index.ts"),
502 export_name: "ReExportedType".to_string(),
503 is_type_only: true,
504 line: 3,
505 col: 0,
506 span_start: 0,
507 is_re_export: true,
508 });
509
510 let lines = build_compact_lines(&results, &root);
511 assert_eq!(
512 lines[0],
513 "unused-re-export-type:src/index.ts:3:ReExportedType"
514 );
515 }
516
517 #[test]
520 fn compact_unused_optional_dep_format() {
521 let root = PathBuf::from("/project");
522 let mut results = AnalysisResults::default();
523 results.unused_optional_dependencies.push(UnusedDependency {
524 package_name: "fsevents".to_string(),
525 location: DependencyLocation::OptionalDependencies,
526 path: root.join("package.json"),
527 line: 12,
528 });
529
530 let lines = build_compact_lines(&results, &root);
531 assert_eq!(lines[0], "unused-optionaldep:fsevents");
532 }
533
534 #[test]
537 fn compact_circular_dependency_format() {
538 let root = PathBuf::from("/project");
539 let mut results = AnalysisResults::default();
540 results.circular_dependencies.push(CircularDependency {
541 files: vec![root.join("src/a.ts"), root.join("src/b.ts")],
542 length: 2,
543 line: 3,
544 col: 0,
545 is_cross_package: false,
546 });
547
548 let lines = build_compact_lines(&results, &root);
549 assert_eq!(lines.len(), 1);
550 assert!(lines[0].starts_with("circular-dependency:src/a.ts:3:"));
551 assert!(lines[0].contains("src/a.ts"));
552 assert!(lines[0].contains("src/b.ts"));
553 assert!(lines[0].contains("\u{2192}"));
555 }
556
557 #[test]
558 fn compact_circular_dependency_closes_cycle() {
559 let root = PathBuf::from("/project");
560 let mut results = AnalysisResults::default();
561 results.circular_dependencies.push(CircularDependency {
562 files: vec![
563 root.join("src/a.ts"),
564 root.join("src/b.ts"),
565 root.join("src/c.ts"),
566 ],
567 length: 3,
568 line: 1,
569 col: 0,
570 is_cross_package: false,
571 });
572
573 let lines = build_compact_lines(&results, &root);
574 let chain_part = lines[0].split(':').next_back().unwrap();
576 let parts: Vec<&str> = chain_part.split(" \u{2192} ").collect();
577 assert_eq!(parts.len(), 4);
578 assert_eq!(parts[0], parts[3]); }
580
581 #[test]
584 fn compact_type_only_dep_format() {
585 let root = PathBuf::from("/project");
586 let mut results = AnalysisResults::default();
587 results.type_only_dependencies.push(TypeOnlyDependency {
588 package_name: "zod".to_string(),
589 path: root.join("package.json"),
590 line: 8,
591 });
592
593 let lines = build_compact_lines(&results, &root);
594 assert_eq!(lines[0], "type-only-dep:zod");
595 }
596
597 #[test]
600 fn compact_multiple_unused_files() {
601 let root = PathBuf::from("/project");
602 let mut results = AnalysisResults::default();
603 results.unused_files.push(UnusedFile {
604 path: root.join("src/a.ts"),
605 });
606 results.unused_files.push(UnusedFile {
607 path: root.join("src/b.ts"),
608 });
609
610 let lines = build_compact_lines(&results, &root);
611 assert_eq!(lines.len(), 2);
612 assert_eq!(lines[0], "unused-file:src/a.ts");
613 assert_eq!(lines[1], "unused-file:src/b.ts");
614 }
615
616 #[test]
619 fn compact_ordering_optional_dep_between_devdep_and_enum() {
620 let root = PathBuf::from("/project");
621 let mut results = AnalysisResults::default();
622 results.unused_dev_dependencies.push(UnusedDependency {
623 package_name: "jest".to_string(),
624 location: DependencyLocation::DevDependencies,
625 path: root.join("package.json"),
626 line: 5,
627 });
628 results.unused_optional_dependencies.push(UnusedDependency {
629 package_name: "fsevents".to_string(),
630 location: DependencyLocation::OptionalDependencies,
631 path: root.join("package.json"),
632 line: 12,
633 });
634 results.unused_enum_members.push(UnusedMember {
635 path: root.join("src/enums.ts"),
636 parent_name: "Status".to_string(),
637 member_name: "Deprecated".to_string(),
638 kind: MemberKind::EnumMember,
639 line: 8,
640 col: 2,
641 });
642
643 let lines = build_compact_lines(&results, &root);
644 assert_eq!(lines.len(), 3);
645 assert!(lines[0].starts_with("unused-devdep:"));
646 assert!(lines[1].starts_with("unused-optionaldep:"));
647 assert!(lines[2].starts_with("unused-enum-member:"));
648 }
649
650 #[test]
653 fn compact_path_outside_root_preserved() {
654 let root = PathBuf::from("/project");
655 let mut results = AnalysisResults::default();
656 results.unused_files.push(UnusedFile {
657 path: PathBuf::from("/other/place/file.ts"),
658 });
659
660 let lines = build_compact_lines(&results, &root);
661 assert!(lines[0].contains("/other/place/file.ts"));
662 }
663}