1use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7use crate::extract::MemberKind;
8use crate::serde_path;
9
10#[derive(Debug, Clone, Default)]
15pub struct EntryPointSummary {
16 pub total: usize,
18 pub by_source: Vec<(String, usize)>,
21}
22
23#[derive(Debug, Default, Clone, Serialize)]
42pub struct AnalysisResults {
43 pub unused_files: Vec<UnusedFile>,
45 pub unused_exports: Vec<UnusedExport>,
47 pub unused_types: Vec<UnusedExport>,
49 pub unused_dependencies: Vec<UnusedDependency>,
51 pub unused_dev_dependencies: Vec<UnusedDependency>,
53 pub unused_optional_dependencies: Vec<UnusedDependency>,
55 pub unused_enum_members: Vec<UnusedMember>,
57 pub unused_class_members: Vec<UnusedMember>,
59 pub unresolved_imports: Vec<UnresolvedImport>,
61 pub unlisted_dependencies: Vec<UnlistedDependency>,
63 pub duplicate_exports: Vec<DuplicateExport>,
65 pub type_only_dependencies: Vec<TypeOnlyDependency>,
68 #[serde(default)]
70 pub test_only_dependencies: Vec<TestOnlyDependency>,
71 pub circular_dependencies: Vec<CircularDependency>,
73 #[serde(default)]
75 pub boundary_violations: Vec<BoundaryViolation>,
76 #[serde(skip)]
80 pub export_usages: Vec<ExportUsage>,
81 #[serde(skip)]
85 pub entry_point_summary: Option<EntryPointSummary>,
86}
87
88impl AnalysisResults {
89 #[must_use]
113 pub const fn total_issues(&self) -> usize {
114 self.unused_files.len()
115 + self.unused_exports.len()
116 + self.unused_types.len()
117 + self.unused_dependencies.len()
118 + self.unused_dev_dependencies.len()
119 + self.unused_optional_dependencies.len()
120 + self.unused_enum_members.len()
121 + self.unused_class_members.len()
122 + self.unresolved_imports.len()
123 + self.unlisted_dependencies.len()
124 + self.duplicate_exports.len()
125 + self.type_only_dependencies.len()
126 + self.test_only_dependencies.len()
127 + self.circular_dependencies.len()
128 + self.boundary_violations.len()
129 }
130
131 #[must_use]
133 pub const fn has_issues(&self) -> bool {
134 self.total_issues() > 0
135 }
136
137 pub fn sort(&mut self) {
144 self.unused_files.sort_by(|a, b| a.path.cmp(&b.path));
145
146 self.unused_exports.sort_by(|a, b| {
147 a.path
148 .cmp(&b.path)
149 .then(a.line.cmp(&b.line))
150 .then(a.export_name.cmp(&b.export_name))
151 });
152
153 self.unused_types.sort_by(|a, b| {
154 a.path
155 .cmp(&b.path)
156 .then(a.line.cmp(&b.line))
157 .then(a.export_name.cmp(&b.export_name))
158 });
159
160 self.unused_dependencies.sort_by(|a, b| {
161 a.path
162 .cmp(&b.path)
163 .then(a.line.cmp(&b.line))
164 .then(a.package_name.cmp(&b.package_name))
165 });
166
167 self.unused_dev_dependencies.sort_by(|a, b| {
168 a.path
169 .cmp(&b.path)
170 .then(a.line.cmp(&b.line))
171 .then(a.package_name.cmp(&b.package_name))
172 });
173
174 self.unused_optional_dependencies.sort_by(|a, b| {
175 a.path
176 .cmp(&b.path)
177 .then(a.line.cmp(&b.line))
178 .then(a.package_name.cmp(&b.package_name))
179 });
180
181 self.unused_enum_members.sort_by(|a, b| {
182 a.path
183 .cmp(&b.path)
184 .then(a.line.cmp(&b.line))
185 .then(a.parent_name.cmp(&b.parent_name))
186 .then(a.member_name.cmp(&b.member_name))
187 });
188
189 self.unused_class_members.sort_by(|a, b| {
190 a.path
191 .cmp(&b.path)
192 .then(a.line.cmp(&b.line))
193 .then(a.parent_name.cmp(&b.parent_name))
194 .then(a.member_name.cmp(&b.member_name))
195 });
196
197 self.unresolved_imports.sort_by(|a, b| {
198 a.path
199 .cmp(&b.path)
200 .then(a.line.cmp(&b.line))
201 .then(a.col.cmp(&b.col))
202 .then(a.specifier.cmp(&b.specifier))
203 });
204
205 self.unlisted_dependencies
206 .sort_by(|a, b| a.package_name.cmp(&b.package_name));
207 for dep in &mut self.unlisted_dependencies {
208 dep.imported_from
209 .sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
210 }
211
212 self.duplicate_exports
213 .sort_by(|a, b| a.export_name.cmp(&b.export_name));
214 for dup in &mut self.duplicate_exports {
215 dup.locations
216 .sort_by(|a, b| a.path.cmp(&b.path).then(a.line.cmp(&b.line)));
217 }
218
219 self.type_only_dependencies.sort_by(|a, b| {
220 a.path
221 .cmp(&b.path)
222 .then(a.line.cmp(&b.line))
223 .then(a.package_name.cmp(&b.package_name))
224 });
225
226 self.test_only_dependencies.sort_by(|a, b| {
227 a.path
228 .cmp(&b.path)
229 .then(a.line.cmp(&b.line))
230 .then(a.package_name.cmp(&b.package_name))
231 });
232
233 self.circular_dependencies
234 .sort_by(|a, b| a.files.cmp(&b.files).then(a.length.cmp(&b.length)));
235
236 self.boundary_violations.sort_by(|a, b| {
237 a.from_path
238 .cmp(&b.from_path)
239 .then(a.line.cmp(&b.line))
240 .then(a.col.cmp(&b.col))
241 .then(a.to_path.cmp(&b.to_path))
242 });
243
244 for usage in &mut self.export_usages {
245 usage.reference_locations.sort_by(|a, b| {
246 a.path
247 .cmp(&b.path)
248 .then(a.line.cmp(&b.line))
249 .then(a.col.cmp(&b.col))
250 });
251 }
252 self.export_usages.sort_by(|a, b| {
253 a.path
254 .cmp(&b.path)
255 .then(a.line.cmp(&b.line))
256 .then(a.export_name.cmp(&b.export_name))
257 });
258 }
259}
260
261#[derive(Debug, Clone, Serialize)]
263pub struct UnusedFile {
264 #[serde(serialize_with = "serde_path::serialize")]
266 pub path: PathBuf,
267}
268
269#[derive(Debug, Clone, Serialize)]
271pub struct UnusedExport {
272 #[serde(serialize_with = "serde_path::serialize")]
274 pub path: PathBuf,
275 pub export_name: String,
277 pub is_type_only: bool,
279 pub line: u32,
281 pub col: u32,
283 pub span_start: u32,
285 pub is_re_export: bool,
287}
288
289#[derive(Debug, Clone, Serialize)]
291pub struct UnusedDependency {
292 pub package_name: String,
294 pub location: DependencyLocation,
296 #[serde(serialize_with = "serde_path::serialize")]
299 pub path: PathBuf,
300 pub line: u32,
302}
303
304#[derive(Debug, Clone, Serialize)]
321#[serde(rename_all = "camelCase")]
322pub enum DependencyLocation {
323 Dependencies,
325 DevDependencies,
327 OptionalDependencies,
329}
330
331#[derive(Debug, Clone, Serialize)]
333pub struct UnusedMember {
334 #[serde(serialize_with = "serde_path::serialize")]
336 pub path: PathBuf,
337 pub parent_name: String,
339 pub member_name: String,
341 pub kind: MemberKind,
343 pub line: u32,
345 pub col: u32,
347}
348
349#[derive(Debug, Clone, Serialize)]
351pub struct UnresolvedImport {
352 #[serde(serialize_with = "serde_path::serialize")]
354 pub path: PathBuf,
355 pub specifier: String,
357 pub line: u32,
359 pub col: u32,
361 pub specifier_col: u32,
364}
365
366#[derive(Debug, Clone, Serialize)]
368pub struct UnlistedDependency {
369 pub package_name: String,
371 pub imported_from: Vec<ImportSite>,
373}
374
375#[derive(Debug, Clone, Serialize)]
377pub struct ImportSite {
378 #[serde(serialize_with = "serde_path::serialize")]
380 pub path: PathBuf,
381 pub line: u32,
383 pub col: u32,
385}
386
387#[derive(Debug, Clone, Serialize)]
389pub struct DuplicateExport {
390 pub export_name: String,
392 pub locations: Vec<DuplicateLocation>,
394}
395
396#[derive(Debug, Clone, Serialize)]
398pub struct DuplicateLocation {
399 #[serde(serialize_with = "serde_path::serialize")]
401 pub path: PathBuf,
402 pub line: u32,
404 pub col: u32,
406}
407
408#[derive(Debug, Clone, Serialize)]
412pub struct TypeOnlyDependency {
413 pub package_name: String,
415 #[serde(serialize_with = "serde_path::serialize")]
417 pub path: PathBuf,
418 pub line: u32,
420}
421
422#[derive(Debug, Clone, Serialize)]
425pub struct TestOnlyDependency {
426 pub package_name: String,
428 #[serde(serialize_with = "serde_path::serialize")]
430 pub path: PathBuf,
431 pub line: u32,
433}
434
435#[derive(Debug, Clone, Serialize, Deserialize)]
437pub struct CircularDependency {
438 #[serde(serialize_with = "serde_path::serialize_vec")]
440 pub files: Vec<PathBuf>,
441 pub length: usize,
443 #[serde(default)]
445 pub line: u32,
446 #[serde(default)]
448 pub col: u32,
449 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
451 pub is_cross_package: bool,
452}
453
454#[derive(Debug, Clone, Serialize)]
456pub struct BoundaryViolation {
457 #[serde(serialize_with = "serde_path::serialize")]
459 pub from_path: PathBuf,
460 #[serde(serialize_with = "serde_path::serialize")]
462 pub to_path: PathBuf,
463 pub from_zone: String,
465 pub to_zone: String,
467 pub import_specifier: String,
469 pub line: u32,
471 pub col: u32,
473}
474
475#[derive(Debug, Clone, Serialize)]
478pub struct ExportUsage {
479 #[serde(serialize_with = "serde_path::serialize")]
481 pub path: PathBuf,
482 pub export_name: String,
484 pub line: u32,
486 pub col: u32,
488 pub reference_count: usize,
490 pub reference_locations: Vec<ReferenceLocation>,
493}
494
495#[derive(Debug, Clone, Serialize)]
497pub struct ReferenceLocation {
498 #[serde(serialize_with = "serde_path::serialize")]
500 pub path: PathBuf,
501 pub line: u32,
503 pub col: u32,
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510
511 #[test]
512 fn empty_results_no_issues() {
513 let results = AnalysisResults::default();
514 assert_eq!(results.total_issues(), 0);
515 assert!(!results.has_issues());
516 }
517
518 #[test]
519 fn results_with_unused_file() {
520 let mut results = AnalysisResults::default();
521 results.unused_files.push(UnusedFile {
522 path: PathBuf::from("test.ts"),
523 });
524 assert_eq!(results.total_issues(), 1);
525 assert!(results.has_issues());
526 }
527
528 #[test]
529 fn results_with_unused_export() {
530 let mut results = AnalysisResults::default();
531 results.unused_exports.push(UnusedExport {
532 path: PathBuf::from("test.ts"),
533 export_name: "foo".to_string(),
534 is_type_only: false,
535 line: 1,
536 col: 0,
537 span_start: 0,
538 is_re_export: false,
539 });
540 assert_eq!(results.total_issues(), 1);
541 assert!(results.has_issues());
542 }
543
544 #[test]
545 fn results_total_counts_all_types() {
546 let mut results = AnalysisResults::default();
547 results.unused_files.push(UnusedFile {
548 path: PathBuf::from("a.ts"),
549 });
550 results.unused_exports.push(UnusedExport {
551 path: PathBuf::from("b.ts"),
552 export_name: "x".to_string(),
553 is_type_only: false,
554 line: 1,
555 col: 0,
556 span_start: 0,
557 is_re_export: false,
558 });
559 results.unused_types.push(UnusedExport {
560 path: PathBuf::from("c.ts"),
561 export_name: "T".to_string(),
562 is_type_only: true,
563 line: 1,
564 col: 0,
565 span_start: 0,
566 is_re_export: false,
567 });
568 results.unused_dependencies.push(UnusedDependency {
569 package_name: "dep".to_string(),
570 location: DependencyLocation::Dependencies,
571 path: PathBuf::from("package.json"),
572 line: 5,
573 });
574 results.unused_dev_dependencies.push(UnusedDependency {
575 package_name: "dev".to_string(),
576 location: DependencyLocation::DevDependencies,
577 path: PathBuf::from("package.json"),
578 line: 5,
579 });
580 results.unused_enum_members.push(UnusedMember {
581 path: PathBuf::from("d.ts"),
582 parent_name: "E".to_string(),
583 member_name: "A".to_string(),
584 kind: MemberKind::EnumMember,
585 line: 1,
586 col: 0,
587 });
588 results.unused_class_members.push(UnusedMember {
589 path: PathBuf::from("e.ts"),
590 parent_name: "C".to_string(),
591 member_name: "m".to_string(),
592 kind: MemberKind::ClassMethod,
593 line: 1,
594 col: 0,
595 });
596 results.unresolved_imports.push(UnresolvedImport {
597 path: PathBuf::from("f.ts"),
598 specifier: "./missing".to_string(),
599 line: 1,
600 col: 0,
601 specifier_col: 0,
602 });
603 results.unlisted_dependencies.push(UnlistedDependency {
604 package_name: "unlisted".to_string(),
605 imported_from: vec![ImportSite {
606 path: PathBuf::from("g.ts"),
607 line: 1,
608 col: 0,
609 }],
610 });
611 results.duplicate_exports.push(DuplicateExport {
612 export_name: "dup".to_string(),
613 locations: vec![
614 DuplicateLocation {
615 path: PathBuf::from("h.ts"),
616 line: 15,
617 col: 0,
618 },
619 DuplicateLocation {
620 path: PathBuf::from("i.ts"),
621 line: 30,
622 col: 0,
623 },
624 ],
625 });
626 results.unused_optional_dependencies.push(UnusedDependency {
627 package_name: "optional".to_string(),
628 location: DependencyLocation::OptionalDependencies,
629 path: PathBuf::from("package.json"),
630 line: 5,
631 });
632 results.type_only_dependencies.push(TypeOnlyDependency {
633 package_name: "type-only".to_string(),
634 path: PathBuf::from("package.json"),
635 line: 8,
636 });
637 results.test_only_dependencies.push(TestOnlyDependency {
638 package_name: "test-only".to_string(),
639 path: PathBuf::from("package.json"),
640 line: 9,
641 });
642 results.circular_dependencies.push(CircularDependency {
643 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
644 length: 2,
645 line: 3,
646 col: 0,
647 is_cross_package: false,
648 });
649 results.boundary_violations.push(BoundaryViolation {
650 from_path: PathBuf::from("src/ui/Button.tsx"),
651 to_path: PathBuf::from("src/db/queries.ts"),
652 from_zone: "ui".to_string(),
653 to_zone: "database".to_string(),
654 import_specifier: "../db/queries".to_string(),
655 line: 3,
656 col: 0,
657 });
658
659 assert_eq!(results.total_issues(), 15);
661 assert!(results.has_issues());
662 }
663
664 #[test]
667 fn total_issues_and_has_issues_are_consistent() {
668 let results = AnalysisResults::default();
669 assert_eq!(results.total_issues(), 0);
670 assert!(!results.has_issues());
671 assert_eq!(results.total_issues() > 0, results.has_issues());
672 }
673
674 #[test]
677 fn total_issues_sums_all_categories_independently() {
678 let mut results = AnalysisResults::default();
679 results.unused_files.push(UnusedFile {
680 path: PathBuf::from("a.ts"),
681 });
682 assert_eq!(results.total_issues(), 1);
683
684 results.unused_files.push(UnusedFile {
685 path: PathBuf::from("b.ts"),
686 });
687 assert_eq!(results.total_issues(), 2);
688
689 results.unresolved_imports.push(UnresolvedImport {
690 path: PathBuf::from("c.ts"),
691 specifier: "./missing".to_string(),
692 line: 1,
693 col: 0,
694 specifier_col: 0,
695 });
696 assert_eq!(results.total_issues(), 3);
697 }
698
699 #[test]
702 fn default_results_all_fields_empty() {
703 let r = AnalysisResults::default();
704 assert!(r.unused_files.is_empty());
705 assert!(r.unused_exports.is_empty());
706 assert!(r.unused_types.is_empty());
707 assert!(r.unused_dependencies.is_empty());
708 assert!(r.unused_dev_dependencies.is_empty());
709 assert!(r.unused_optional_dependencies.is_empty());
710 assert!(r.unused_enum_members.is_empty());
711 assert!(r.unused_class_members.is_empty());
712 assert!(r.unresolved_imports.is_empty());
713 assert!(r.unlisted_dependencies.is_empty());
714 assert!(r.duplicate_exports.is_empty());
715 assert!(r.type_only_dependencies.is_empty());
716 assert!(r.test_only_dependencies.is_empty());
717 assert!(r.circular_dependencies.is_empty());
718 assert!(r.boundary_violations.is_empty());
719 assert!(r.export_usages.is_empty());
720 }
721
722 #[test]
725 fn entry_point_summary_default() {
726 let summary = EntryPointSummary::default();
727 assert_eq!(summary.total, 0);
728 assert!(summary.by_source.is_empty());
729 }
730
731 #[test]
732 fn entry_point_summary_not_in_default_results() {
733 let r = AnalysisResults::default();
734 assert!(r.entry_point_summary.is_none());
735 }
736
737 #[test]
738 fn entry_point_summary_some_preserves_data() {
739 let r = AnalysisResults {
740 entry_point_summary: Some(EntryPointSummary {
741 total: 5,
742 by_source: vec![("package.json".to_string(), 2), ("plugin".to_string(), 3)],
743 }),
744 ..AnalysisResults::default()
745 };
746 let summary = r.entry_point_summary.as_ref().unwrap();
747 assert_eq!(summary.total, 5);
748 assert_eq!(summary.by_source.len(), 2);
749 assert_eq!(summary.by_source[0], ("package.json".to_string(), 2));
750 }
751
752 #[test]
755 fn sort_unused_files_by_path() {
756 let mut r = AnalysisResults::default();
757 r.unused_files.push(UnusedFile {
758 path: PathBuf::from("z.ts"),
759 });
760 r.unused_files.push(UnusedFile {
761 path: PathBuf::from("a.ts"),
762 });
763 r.unused_files.push(UnusedFile {
764 path: PathBuf::from("m.ts"),
765 });
766 r.sort();
767 let paths: Vec<_> = r
768 .unused_files
769 .iter()
770 .map(|f| f.path.to_string_lossy().to_string())
771 .collect();
772 assert_eq!(paths, vec!["a.ts", "m.ts", "z.ts"]);
773 }
774
775 #[test]
778 fn sort_unused_exports_by_path_line_name() {
779 let mut r = AnalysisResults::default();
780 let mk = |path: &str, line: u32, name: &str| UnusedExport {
781 path: PathBuf::from(path),
782 export_name: name.to_string(),
783 is_type_only: false,
784 line,
785 col: 0,
786 span_start: 0,
787 is_re_export: false,
788 };
789 r.unused_exports.push(mk("b.ts", 5, "beta"));
790 r.unused_exports.push(mk("a.ts", 10, "zeta"));
791 r.unused_exports.push(mk("a.ts", 10, "alpha"));
792 r.unused_exports.push(mk("a.ts", 1, "gamma"));
793 r.sort();
794 let keys: Vec<_> = r
795 .unused_exports
796 .iter()
797 .map(|e| format!("{}:{}:{}", e.path.to_string_lossy(), e.line, e.export_name))
798 .collect();
799 assert_eq!(
800 keys,
801 vec![
802 "a.ts:1:gamma",
803 "a.ts:10:alpha",
804 "a.ts:10:zeta",
805 "b.ts:5:beta"
806 ]
807 );
808 }
809
810 #[test]
813 fn sort_unused_types_by_path_line_name() {
814 let mut r = AnalysisResults::default();
815 let mk = |path: &str, line: u32, name: &str| UnusedExport {
816 path: PathBuf::from(path),
817 export_name: name.to_string(),
818 is_type_only: true,
819 line,
820 col: 0,
821 span_start: 0,
822 is_re_export: false,
823 };
824 r.unused_types.push(mk("z.ts", 1, "Z"));
825 r.unused_types.push(mk("a.ts", 1, "A"));
826 r.sort();
827 assert_eq!(r.unused_types[0].path, PathBuf::from("a.ts"));
828 assert_eq!(r.unused_types[1].path, PathBuf::from("z.ts"));
829 }
830
831 #[test]
834 fn sort_unused_dependencies_by_path_line_name() {
835 let mut r = AnalysisResults::default();
836 let mk = |path: &str, line: u32, name: &str| UnusedDependency {
837 package_name: name.to_string(),
838 location: DependencyLocation::Dependencies,
839 path: PathBuf::from(path),
840 line,
841 };
842 r.unused_dependencies.push(mk("b/package.json", 3, "zlib"));
843 r.unused_dependencies.push(mk("a/package.json", 5, "react"));
844 r.unused_dependencies.push(mk("a/package.json", 5, "axios"));
845 r.sort();
846 let names: Vec<_> = r
847 .unused_dependencies
848 .iter()
849 .map(|d| d.package_name.as_str())
850 .collect();
851 assert_eq!(names, vec!["axios", "react", "zlib"]);
852 }
853
854 #[test]
857 fn sort_unused_dev_dependencies() {
858 let mut r = AnalysisResults::default();
859 r.unused_dev_dependencies.push(UnusedDependency {
860 package_name: "vitest".to_string(),
861 location: DependencyLocation::DevDependencies,
862 path: PathBuf::from("package.json"),
863 line: 10,
864 });
865 r.unused_dev_dependencies.push(UnusedDependency {
866 package_name: "jest".to_string(),
867 location: DependencyLocation::DevDependencies,
868 path: PathBuf::from("package.json"),
869 line: 5,
870 });
871 r.sort();
872 assert_eq!(r.unused_dev_dependencies[0].package_name, "jest");
873 assert_eq!(r.unused_dev_dependencies[1].package_name, "vitest");
874 }
875
876 #[test]
879 fn sort_unused_optional_dependencies() {
880 let mut r = AnalysisResults::default();
881 r.unused_optional_dependencies.push(UnusedDependency {
882 package_name: "zod".to_string(),
883 location: DependencyLocation::OptionalDependencies,
884 path: PathBuf::from("package.json"),
885 line: 3,
886 });
887 r.unused_optional_dependencies.push(UnusedDependency {
888 package_name: "ajv".to_string(),
889 location: DependencyLocation::OptionalDependencies,
890 path: PathBuf::from("package.json"),
891 line: 2,
892 });
893 r.sort();
894 assert_eq!(r.unused_optional_dependencies[0].package_name, "ajv");
895 assert_eq!(r.unused_optional_dependencies[1].package_name, "zod");
896 }
897
898 #[test]
901 fn sort_unused_enum_members_by_path_line_parent_member() {
902 let mut r = AnalysisResults::default();
903 let mk = |path: &str, line: u32, parent: &str, member: &str| UnusedMember {
904 path: PathBuf::from(path),
905 parent_name: parent.to_string(),
906 member_name: member.to_string(),
907 kind: MemberKind::EnumMember,
908 line,
909 col: 0,
910 };
911 r.unused_enum_members.push(mk("a.ts", 5, "Status", "Z"));
912 r.unused_enum_members.push(mk("a.ts", 5, "Status", "A"));
913 r.unused_enum_members.push(mk("a.ts", 1, "Direction", "Up"));
914 r.sort();
915 let keys: Vec<_> = r
916 .unused_enum_members
917 .iter()
918 .map(|m| format!("{}:{}", m.parent_name, m.member_name))
919 .collect();
920 assert_eq!(keys, vec!["Direction:Up", "Status:A", "Status:Z"]);
921 }
922
923 #[test]
926 fn sort_unused_class_members() {
927 let mut r = AnalysisResults::default();
928 let mk = |path: &str, line: u32, parent: &str, member: &str| UnusedMember {
929 path: PathBuf::from(path),
930 parent_name: parent.to_string(),
931 member_name: member.to_string(),
932 kind: MemberKind::ClassMethod,
933 line,
934 col: 0,
935 };
936 r.unused_class_members.push(mk("b.ts", 1, "Foo", "z"));
937 r.unused_class_members.push(mk("a.ts", 1, "Bar", "a"));
938 r.sort();
939 assert_eq!(r.unused_class_members[0].path, PathBuf::from("a.ts"));
940 assert_eq!(r.unused_class_members[1].path, PathBuf::from("b.ts"));
941 }
942
943 #[test]
946 fn sort_unresolved_imports_by_path_line_col_specifier() {
947 let mut r = AnalysisResults::default();
948 let mk = |path: &str, line: u32, col: u32, spec: &str| UnresolvedImport {
949 path: PathBuf::from(path),
950 specifier: spec.to_string(),
951 line,
952 col,
953 specifier_col: 0,
954 };
955 r.unresolved_imports.push(mk("a.ts", 5, 0, "./z"));
956 r.unresolved_imports.push(mk("a.ts", 5, 0, "./a"));
957 r.unresolved_imports.push(mk("a.ts", 1, 0, "./m"));
958 r.sort();
959 let specs: Vec<_> = r
960 .unresolved_imports
961 .iter()
962 .map(|i| i.specifier.as_str())
963 .collect();
964 assert_eq!(specs, vec!["./m", "./a", "./z"]);
965 }
966
967 #[test]
970 fn sort_unlisted_dependencies_by_name_and_inner_sites() {
971 let mut r = AnalysisResults::default();
972 r.unlisted_dependencies.push(UnlistedDependency {
973 package_name: "zod".to_string(),
974 imported_from: vec![
975 ImportSite {
976 path: PathBuf::from("b.ts"),
977 line: 10,
978 col: 0,
979 },
980 ImportSite {
981 path: PathBuf::from("a.ts"),
982 line: 1,
983 col: 0,
984 },
985 ],
986 });
987 r.unlisted_dependencies.push(UnlistedDependency {
988 package_name: "axios".to_string(),
989 imported_from: vec![ImportSite {
990 path: PathBuf::from("c.ts"),
991 line: 1,
992 col: 0,
993 }],
994 });
995 r.sort();
996
997 assert_eq!(r.unlisted_dependencies[0].package_name, "axios");
999 assert_eq!(r.unlisted_dependencies[1].package_name, "zod");
1000
1001 let zod_sites: Vec<_> = r.unlisted_dependencies[1]
1003 .imported_from
1004 .iter()
1005 .map(|s| s.path.to_string_lossy().to_string())
1006 .collect();
1007 assert_eq!(zod_sites, vec!["a.ts", "b.ts"]);
1008 }
1009
1010 #[test]
1013 fn sort_duplicate_exports_by_name_and_inner_locations() {
1014 let mut r = AnalysisResults::default();
1015 r.duplicate_exports.push(DuplicateExport {
1016 export_name: "z".to_string(),
1017 locations: vec![
1018 DuplicateLocation {
1019 path: PathBuf::from("c.ts"),
1020 line: 1,
1021 col: 0,
1022 },
1023 DuplicateLocation {
1024 path: PathBuf::from("a.ts"),
1025 line: 5,
1026 col: 0,
1027 },
1028 ],
1029 });
1030 r.duplicate_exports.push(DuplicateExport {
1031 export_name: "a".to_string(),
1032 locations: vec![DuplicateLocation {
1033 path: PathBuf::from("b.ts"),
1034 line: 1,
1035 col: 0,
1036 }],
1037 });
1038 r.sort();
1039
1040 assert_eq!(r.duplicate_exports[0].export_name, "a");
1042 assert_eq!(r.duplicate_exports[1].export_name, "z");
1043
1044 let z_locs: Vec<_> = r.duplicate_exports[1]
1046 .locations
1047 .iter()
1048 .map(|l| l.path.to_string_lossy().to_string())
1049 .collect();
1050 assert_eq!(z_locs, vec!["a.ts", "c.ts"]);
1051 }
1052
1053 #[test]
1056 fn sort_type_only_dependencies() {
1057 let mut r = AnalysisResults::default();
1058 r.type_only_dependencies.push(TypeOnlyDependency {
1059 package_name: "zod".to_string(),
1060 path: PathBuf::from("package.json"),
1061 line: 10,
1062 });
1063 r.type_only_dependencies.push(TypeOnlyDependency {
1064 package_name: "ajv".to_string(),
1065 path: PathBuf::from("package.json"),
1066 line: 5,
1067 });
1068 r.sort();
1069 assert_eq!(r.type_only_dependencies[0].package_name, "ajv");
1070 assert_eq!(r.type_only_dependencies[1].package_name, "zod");
1071 }
1072
1073 #[test]
1076 fn sort_test_only_dependencies() {
1077 let mut r = AnalysisResults::default();
1078 r.test_only_dependencies.push(TestOnlyDependency {
1079 package_name: "vitest".to_string(),
1080 path: PathBuf::from("package.json"),
1081 line: 15,
1082 });
1083 r.test_only_dependencies.push(TestOnlyDependency {
1084 package_name: "jest".to_string(),
1085 path: PathBuf::from("package.json"),
1086 line: 10,
1087 });
1088 r.sort();
1089 assert_eq!(r.test_only_dependencies[0].package_name, "jest");
1090 assert_eq!(r.test_only_dependencies[1].package_name, "vitest");
1091 }
1092
1093 #[test]
1096 fn sort_circular_dependencies_by_files_then_length() {
1097 let mut r = AnalysisResults::default();
1098 r.circular_dependencies.push(CircularDependency {
1099 files: vec![PathBuf::from("b.ts"), PathBuf::from("c.ts")],
1100 length: 2,
1101 line: 1,
1102 col: 0,
1103 is_cross_package: false,
1104 });
1105 r.circular_dependencies.push(CircularDependency {
1106 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
1107 length: 2,
1108 line: 1,
1109 col: 0,
1110 is_cross_package: true,
1111 });
1112 r.sort();
1113 assert_eq!(r.circular_dependencies[0].files[0], PathBuf::from("a.ts"));
1114 assert_eq!(r.circular_dependencies[1].files[0], PathBuf::from("b.ts"));
1115 }
1116
1117 #[test]
1120 fn sort_boundary_violations() {
1121 let mut r = AnalysisResults::default();
1122 let mk = |from: &str, line: u32, col: u32, to: &str| BoundaryViolation {
1123 from_path: PathBuf::from(from),
1124 to_path: PathBuf::from(to),
1125 from_zone: "a".to_string(),
1126 to_zone: "b".to_string(),
1127 import_specifier: to.to_string(),
1128 line,
1129 col,
1130 };
1131 r.boundary_violations.push(mk("z.ts", 1, 0, "a.ts"));
1132 r.boundary_violations.push(mk("a.ts", 5, 0, "b.ts"));
1133 r.boundary_violations.push(mk("a.ts", 1, 0, "c.ts"));
1134 r.sort();
1135 let from_paths: Vec<_> = r
1136 .boundary_violations
1137 .iter()
1138 .map(|v| format!("{}:{}", v.from_path.to_string_lossy(), v.line))
1139 .collect();
1140 assert_eq!(from_paths, vec!["a.ts:1", "a.ts:5", "z.ts:1"]);
1141 }
1142
1143 #[test]
1146 fn sort_export_usages_and_inner_reference_locations() {
1147 let mut r = AnalysisResults::default();
1148 r.export_usages.push(ExportUsage {
1149 path: PathBuf::from("z.ts"),
1150 export_name: "foo".to_string(),
1151 line: 1,
1152 col: 0,
1153 reference_count: 2,
1154 reference_locations: vec![
1155 ReferenceLocation {
1156 path: PathBuf::from("c.ts"),
1157 line: 10,
1158 col: 0,
1159 },
1160 ReferenceLocation {
1161 path: PathBuf::from("a.ts"),
1162 line: 5,
1163 col: 0,
1164 },
1165 ],
1166 });
1167 r.export_usages.push(ExportUsage {
1168 path: PathBuf::from("a.ts"),
1169 export_name: "bar".to_string(),
1170 line: 1,
1171 col: 0,
1172 reference_count: 1,
1173 reference_locations: vec![ReferenceLocation {
1174 path: PathBuf::from("b.ts"),
1175 line: 1,
1176 col: 0,
1177 }],
1178 });
1179 r.sort();
1180
1181 assert_eq!(r.export_usages[0].path, PathBuf::from("a.ts"));
1183 assert_eq!(r.export_usages[1].path, PathBuf::from("z.ts"));
1184
1185 let refs: Vec<_> = r.export_usages[1]
1187 .reference_locations
1188 .iter()
1189 .map(|l| l.path.to_string_lossy().to_string())
1190 .collect();
1191 assert_eq!(refs, vec!["a.ts", "c.ts"]);
1192 }
1193
1194 #[test]
1197 fn sort_empty_results_is_noop() {
1198 let mut r = AnalysisResults::default();
1199 r.sort(); assert_eq!(r.total_issues(), 0);
1201 }
1202
1203 #[test]
1206 fn sort_single_element_lists_stable() {
1207 let mut r = AnalysisResults::default();
1208 r.unused_files.push(UnusedFile {
1209 path: PathBuf::from("only.ts"),
1210 });
1211 r.sort();
1212 assert_eq!(r.unused_files[0].path, PathBuf::from("only.ts"));
1213 }
1214
1215 #[test]
1218 fn serialize_empty_results() {
1219 let r = AnalysisResults::default();
1220 let json = serde_json::to_value(&r).unwrap();
1221
1222 assert!(json["unused_files"].as_array().unwrap().is_empty());
1224 assert!(json["unused_exports"].as_array().unwrap().is_empty());
1225 assert!(json["circular_dependencies"].as_array().unwrap().is_empty());
1226
1227 assert!(json.get("export_usages").is_none());
1229 assert!(json.get("entry_point_summary").is_none());
1230 }
1231
1232 #[test]
1233 fn serialize_unused_file_path() {
1234 let r = UnusedFile {
1235 path: PathBuf::from("src/utils/index.ts"),
1236 };
1237 let json = serde_json::to_value(&r).unwrap();
1238 assert_eq!(json["path"], "src/utils/index.ts");
1239 }
1240
1241 #[test]
1242 fn serialize_dependency_location_camel_case() {
1243 let dep = UnusedDependency {
1244 package_name: "react".to_string(),
1245 location: DependencyLocation::DevDependencies,
1246 path: PathBuf::from("package.json"),
1247 line: 5,
1248 };
1249 let json = serde_json::to_value(&dep).unwrap();
1250 assert_eq!(json["location"], "devDependencies");
1251
1252 let dep2 = UnusedDependency {
1253 package_name: "react".to_string(),
1254 location: DependencyLocation::Dependencies,
1255 path: PathBuf::from("package.json"),
1256 line: 3,
1257 };
1258 let json2 = serde_json::to_value(&dep2).unwrap();
1259 assert_eq!(json2["location"], "dependencies");
1260
1261 let dep3 = UnusedDependency {
1262 package_name: "fsevents".to_string(),
1263 location: DependencyLocation::OptionalDependencies,
1264 path: PathBuf::from("package.json"),
1265 line: 7,
1266 };
1267 let json3 = serde_json::to_value(&dep3).unwrap();
1268 assert_eq!(json3["location"], "optionalDependencies");
1269 }
1270
1271 #[test]
1272 fn serialize_circular_dependency_skips_false_cross_package() {
1273 let cd = CircularDependency {
1274 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
1275 length: 2,
1276 line: 1,
1277 col: 0,
1278 is_cross_package: false,
1279 };
1280 let json = serde_json::to_value(&cd).unwrap();
1281 assert!(json.get("is_cross_package").is_none());
1283 }
1284
1285 #[test]
1286 fn serialize_circular_dependency_includes_true_cross_package() {
1287 let cd = CircularDependency {
1288 files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
1289 length: 2,
1290 line: 1,
1291 col: 0,
1292 is_cross_package: true,
1293 };
1294 let json = serde_json::to_value(&cd).unwrap();
1295 assert_eq!(json["is_cross_package"], true);
1296 }
1297
1298 #[test]
1299 fn serialize_unused_export_fields() {
1300 let e = UnusedExport {
1301 path: PathBuf::from("src/mod.ts"),
1302 export_name: "helper".to_string(),
1303 is_type_only: true,
1304 line: 42,
1305 col: 7,
1306 span_start: 100,
1307 is_re_export: true,
1308 };
1309 let json = serde_json::to_value(&e).unwrap();
1310 assert_eq!(json["path"], "src/mod.ts");
1311 assert_eq!(json["export_name"], "helper");
1312 assert_eq!(json["is_type_only"], true);
1313 assert_eq!(json["line"], 42);
1314 assert_eq!(json["col"], 7);
1315 assert_eq!(json["span_start"], 100);
1316 assert_eq!(json["is_re_export"], true);
1317 }
1318
1319 #[test]
1320 fn serialize_boundary_violation_fields() {
1321 let v = BoundaryViolation {
1322 from_path: PathBuf::from("src/ui/button.tsx"),
1323 to_path: PathBuf::from("src/db/queries.ts"),
1324 from_zone: "ui".to_string(),
1325 to_zone: "db".to_string(),
1326 import_specifier: "../db/queries".to_string(),
1327 line: 3,
1328 col: 0,
1329 };
1330 let json = serde_json::to_value(&v).unwrap();
1331 assert_eq!(json["from_path"], "src/ui/button.tsx");
1332 assert_eq!(json["to_path"], "src/db/queries.ts");
1333 assert_eq!(json["from_zone"], "ui");
1334 assert_eq!(json["to_zone"], "db");
1335 assert_eq!(json["import_specifier"], "../db/queries");
1336 }
1337
1338 #[test]
1339 fn serialize_unlisted_dependency_with_import_sites() {
1340 let d = UnlistedDependency {
1341 package_name: "chalk".to_string(),
1342 imported_from: vec![
1343 ImportSite {
1344 path: PathBuf::from("a.ts"),
1345 line: 1,
1346 col: 0,
1347 },
1348 ImportSite {
1349 path: PathBuf::from("b.ts"),
1350 line: 5,
1351 col: 3,
1352 },
1353 ],
1354 };
1355 let json = serde_json::to_value(&d).unwrap();
1356 assert_eq!(json["package_name"], "chalk");
1357 let sites = json["imported_from"].as_array().unwrap();
1358 assert_eq!(sites.len(), 2);
1359 assert_eq!(sites[0]["path"], "a.ts");
1360 assert_eq!(sites[1]["line"], 5);
1361 }
1362
1363 #[test]
1364 fn serialize_duplicate_export_with_locations() {
1365 let d = DuplicateExport {
1366 export_name: "Button".to_string(),
1367 locations: vec![
1368 DuplicateLocation {
1369 path: PathBuf::from("src/a.ts"),
1370 line: 10,
1371 col: 0,
1372 },
1373 DuplicateLocation {
1374 path: PathBuf::from("src/b.ts"),
1375 line: 20,
1376 col: 5,
1377 },
1378 ],
1379 };
1380 let json = serde_json::to_value(&d).unwrap();
1381 assert_eq!(json["export_name"], "Button");
1382 let locs = json["locations"].as_array().unwrap();
1383 assert_eq!(locs.len(), 2);
1384 assert_eq!(locs[0]["line"], 10);
1385 assert_eq!(locs[1]["col"], 5);
1386 }
1387
1388 #[test]
1389 fn serialize_type_only_dependency() {
1390 let d = TypeOnlyDependency {
1391 package_name: "@types/react".to_string(),
1392 path: PathBuf::from("package.json"),
1393 line: 12,
1394 };
1395 let json = serde_json::to_value(&d).unwrap();
1396 assert_eq!(json["package_name"], "@types/react");
1397 assert_eq!(json["line"], 12);
1398 }
1399
1400 #[test]
1401 fn serialize_test_only_dependency() {
1402 let d = TestOnlyDependency {
1403 package_name: "vitest".to_string(),
1404 path: PathBuf::from("package.json"),
1405 line: 8,
1406 };
1407 let json = serde_json::to_value(&d).unwrap();
1408 assert_eq!(json["package_name"], "vitest");
1409 assert_eq!(json["line"], 8);
1410 }
1411
1412 #[test]
1413 fn serialize_unused_member() {
1414 let m = UnusedMember {
1415 path: PathBuf::from("enums.ts"),
1416 parent_name: "Status".to_string(),
1417 member_name: "Pending".to_string(),
1418 kind: MemberKind::EnumMember,
1419 line: 3,
1420 col: 4,
1421 };
1422 let json = serde_json::to_value(&m).unwrap();
1423 assert_eq!(json["parent_name"], "Status");
1424 assert_eq!(json["member_name"], "Pending");
1425 assert_eq!(json["line"], 3);
1426 }
1427
1428 #[test]
1429 fn serialize_unresolved_import() {
1430 let i = UnresolvedImport {
1431 path: PathBuf::from("app.ts"),
1432 specifier: "./missing-module".to_string(),
1433 line: 7,
1434 col: 0,
1435 specifier_col: 21,
1436 };
1437 let json = serde_json::to_value(&i).unwrap();
1438 assert_eq!(json["specifier"], "./missing-module");
1439 assert_eq!(json["specifier_col"], 21);
1440 }
1441
1442 #[test]
1445 fn deserialize_circular_dependency_with_defaults() {
1446 let json = r#"{"files":["a.ts","b.ts"],"length":2}"#;
1448 let cd: CircularDependency = serde_json::from_str(json).unwrap();
1449 assert_eq!(cd.files.len(), 2);
1450 assert_eq!(cd.length, 2);
1451 assert_eq!(cd.line, 0);
1452 assert_eq!(cd.col, 0);
1453 assert!(!cd.is_cross_package);
1454 }
1455
1456 #[test]
1457 fn deserialize_circular_dependency_with_all_fields() {
1458 let json =
1459 r#"{"files":["a.ts","b.ts"],"length":2,"line":5,"col":10,"is_cross_package":true}"#;
1460 let cd: CircularDependency = serde_json::from_str(json).unwrap();
1461 assert_eq!(cd.line, 5);
1462 assert_eq!(cd.col, 10);
1463 assert!(cd.is_cross_package);
1464 }
1465
1466 #[test]
1469 fn clone_results_are_independent() {
1470 let mut r = AnalysisResults::default();
1471 r.unused_files.push(UnusedFile {
1472 path: PathBuf::from("a.ts"),
1473 });
1474 let mut cloned = r.clone();
1475 cloned.unused_files.push(UnusedFile {
1476 path: PathBuf::from("b.ts"),
1477 });
1478 assert_eq!(r.total_issues(), 1);
1479 assert_eq!(cloned.total_issues(), 2);
1480 }
1481
1482 #[test]
1485 fn export_usages_not_counted_in_total_issues() {
1486 let mut r = AnalysisResults::default();
1487 r.export_usages.push(ExportUsage {
1488 path: PathBuf::from("mod.ts"),
1489 export_name: "foo".to_string(),
1490 line: 1,
1491 col: 0,
1492 reference_count: 3,
1493 reference_locations: vec![],
1494 });
1495 assert_eq!(r.total_issues(), 0);
1497 assert!(!r.has_issues());
1498 }
1499
1500 #[test]
1503 fn entry_point_summary_not_counted_in_total_issues() {
1504 let r = AnalysisResults {
1505 entry_point_summary: Some(EntryPointSummary {
1506 total: 10,
1507 by_source: vec![("config".to_string(), 10)],
1508 }),
1509 ..AnalysisResults::default()
1510 };
1511 assert_eq!(r.total_issues(), 0);
1512 assert!(!r.has_issues());
1513 }
1514}