1use std::path::{Path, PathBuf};
2
3pub use fallow_types::trace::{
4 CloneTrace, DependencyTrace, ExportReference, ExportTrace, FileTrace, ImpactClosureGap,
5 ImpactClosureTrace, PipelineTimings, ReExportChain, TracedCloneGroup, TracedExport,
6 TracedReExport,
7};
8use rustc_hash::FxHashSet;
9
10use crate::duplicates::{
11 CloneFingerprintSet, CloneGroup, CloneInstance, DuplicationReport, dominant_identifier,
12 group_refactoring_suggestion,
13};
14use crate::graph::{ModuleGraph, ReferenceKind};
15
16fn path_matches(module_path: &Path, root: &Path, user_path: &str) -> bool {
21 let user_path_norm = user_path.replace('\\', "/");
22 let rel = module_path.strip_prefix(root).unwrap_or(module_path);
23 let rel_str = rel.to_string_lossy().replace('\\', "/");
24 let module_str = module_path.to_string_lossy().replace('\\', "/");
25 if rel_str == user_path_norm || module_str == user_path_norm {
26 return true;
27 }
28 if dunce::canonicalize(root).is_ok_and(|canonical_root| {
29 module_path
30 .strip_prefix(&canonical_root)
31 .is_ok_and(|rel| rel.to_string_lossy().replace('\\', "/") == user_path_norm)
32 }) {
33 return true;
34 }
35 module_str.ends_with(&format!("/{user_path_norm}"))
36}
37
38fn reference_to_export_reference(
40 graph: &ModuleGraph,
41 root: &Path,
42 r: &crate::graph::SymbolReference,
43) -> ExportReference {
44 let from_path = graph.modules.get(r.from_file.0 as usize).map_or_else(
45 || PathBuf::from(format!("<unknown:{}>", r.from_file.0)),
46 |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
47 );
48 ExportReference {
49 from_file: from_path,
50 kind: format_reference_kind(r.kind),
51 }
52}
53
54fn collect_re_export_chains(
57 graph: &ModuleGraph,
58 root: &Path,
59 target_file_id: crate::discover::FileId,
60 export_name: &str,
61) -> Vec<ReExportChain> {
62 graph
63 .modules
64 .iter()
65 .flat_map(|m| {
66 m.re_exports
67 .iter()
68 .filter(move |re| {
69 re.source_file == target_file_id
70 && (re.imported_name == export_name || re.imported_name == "*")
71 })
72 .map(move |re| {
73 let barrel_export = m.exports.iter().find(|e| {
74 if re.exported_name == "*" {
75 e.name.to_string() == export_name
76 } else {
77 e.name.to_string() == re.exported_name
78 }
79 });
80 ReExportChain {
81 barrel_file: m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
82 exported_as: re.exported_name.clone(),
83 reference_count: barrel_export.map_or(0, |e| e.references.len()),
84 }
85 })
86 })
87 .collect()
88}
89
90fn export_trace_reason(
92 module: &crate::graph::ModuleNode,
93 reference_count: usize,
94 is_used: bool,
95 re_export_chains: &[ReExportChain],
96) -> String {
97 if !module.is_reachable() {
98 "File is unreachable from any entry point".to_string()
99 } else if is_used {
100 format!(
101 "Used by {} file(s){}",
102 reference_count,
103 if re_export_chains.is_empty() {
104 String::new()
105 } else {
106 format!(", re-exported through {} barrel(s)", re_export_chains.len())
107 }
108 )
109 } else if module.is_entry_point() {
110 "No internal references, but file is an entry point (export is externally accessible)"
111 .to_string()
112 } else if !re_export_chains.is_empty() {
113 format!(
114 "Re-exported through {} barrel(s) but no consumer imports it through the barrel",
115 re_export_chains.len()
116 )
117 } else {
118 "No references found, export is unused".to_string()
119 }
120}
121
122#[must_use]
124pub fn trace_export(
125 graph: &ModuleGraph,
126 root: &Path,
127 file_path: &str,
128 export_name: &str,
129) -> Option<ExportTrace> {
130 let module = graph
131 .modules
132 .iter()
133 .find(|m| path_matches(&m.path, root, file_path))?;
134
135 let export = module
136 .exports
137 .iter()
138 .filter(|e| export_name_matches(e, export_name))
139 .max_by_key(|e| (!e.references.is_empty(), !e.is_type_only))?;
140
141 let direct_references: Vec<ExportReference> = export
142 .references
143 .iter()
144 .map(|r| reference_to_export_reference(graph, root, r))
145 .collect();
146
147 let re_export_chains = collect_re_export_chains(graph, root, module.file_id, export_name);
148
149 let is_used = !export.references.is_empty();
150 let reason = export_trace_reason(module, export.references.len(), is_used, &re_export_chains);
151
152 Some(ExportTrace {
153 file: module
154 .path
155 .strip_prefix(root)
156 .unwrap_or(&module.path)
157 .to_path_buf(),
158 export_name: export_name.to_string(),
159 file_reachable: module.is_reachable(),
160 is_entry_point: module.is_entry_point(),
161 is_used,
162 direct_references,
163 re_export_chains,
164 reason,
165 })
166}
167
168fn export_name_matches(export: &crate::graph::ExportSymbol, export_name: &str) -> bool {
169 let name_str = export.name.to_string();
170 name_str == export_name || (export_name == "default" && name_str == "default")
171}
172
173fn traced_exports(
175 graph: &ModuleGraph,
176 root: &Path,
177 module: &crate::graph::ModuleNode,
178) -> Vec<TracedExport> {
179 module
180 .exports
181 .iter()
182 .map(|e| TracedExport {
183 name: e.name.to_string(),
184 is_type_only: e.is_type_only,
185 reference_count: e.references.len(),
186 referenced_by: e
187 .references
188 .iter()
189 .map(|r| reference_to_export_reference(graph, root, r))
190 .collect(),
191 })
192 .collect()
193}
194
195fn traced_imports_from(
197 graph: &ModuleGraph,
198 root: &Path,
199 module: &crate::graph::ModuleNode,
200) -> Vec<PathBuf> {
201 graph
202 .edges_for(module.file_id)
203 .iter()
204 .filter_map(|target_id| {
205 graph
206 .modules
207 .get(target_id.0 as usize)
208 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
209 })
210 .collect()
211}
212
213fn traced_imported_by(
215 graph: &ModuleGraph,
216 root: &Path,
217 module: &crate::graph::ModuleNode,
218) -> Vec<PathBuf> {
219 graph
220 .reverse_deps
221 .get(module.file_id.0 as usize)
222 .map(|deps| {
223 deps.iter()
224 .filter_map(|fid| {
225 graph
226 .modules
227 .get(fid.0 as usize)
228 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
229 })
230 .collect()
231 })
232 .unwrap_or_default()
233}
234
235fn traced_re_exports(
237 graph: &ModuleGraph,
238 root: &Path,
239 module: &crate::graph::ModuleNode,
240) -> Vec<TracedReExport> {
241 module
242 .re_exports
243 .iter()
244 .map(|re| {
245 let source_path = graph.modules.get(re.source_file.0 as usize).map_or_else(
246 || PathBuf::from(format!("<unknown:{}>", re.source_file.0)),
247 |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
248 );
249 TracedReExport {
250 source_file: source_path,
251 imported_name: re.imported_name.clone(),
252 exported_name: re.exported_name.clone(),
253 }
254 })
255 .collect()
256}
257
258#[must_use]
260pub fn trace_file(graph: &ModuleGraph, root: &Path, file_path: &str) -> Option<FileTrace> {
261 let module = graph
262 .modules
263 .iter()
264 .find(|m| path_matches(&m.path, root, file_path))?;
265
266 Some(FileTrace {
267 file: module
268 .path
269 .strip_prefix(root)
270 .unwrap_or(&module.path)
271 .to_path_buf(),
272 is_reachable: module.is_reachable(),
273 is_entry_point: module.is_entry_point(),
274 exports: traced_exports(graph, root, module),
275 imports_from: traced_imports_from(graph, root, module),
276 imported_by: traced_imported_by(graph, root, module),
277 re_exports: traced_re_exports(graph, root, module),
278 })
279}
280
281#[expect(
290 clippy::implicit_hasher,
291 reason = "fallow standardizes on FxHashSet across the workspace"
292)]
293#[must_use]
294pub fn trace_dependency(
295 graph: &ModuleGraph,
296 root: &Path,
297 package_name: &str,
298 script_used_packages: &FxHashSet<String>,
299) -> DependencyTrace {
300 let imported_by: Vec<PathBuf> = graph
301 .package_usage
302 .get(package_name)
303 .map(|ids| {
304 ids.iter()
305 .filter_map(|fid| {
306 graph
307 .modules
308 .get(fid.0 as usize)
309 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
310 })
311 .collect()
312 })
313 .unwrap_or_default();
314
315 let type_only_imported_by: Vec<PathBuf> = graph
316 .type_only_package_usage
317 .get(package_name)
318 .map(|ids| {
319 ids.iter()
320 .filter_map(|fid| {
321 graph
322 .modules
323 .get(fid.0 as usize)
324 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
325 })
326 .collect()
327 })
328 .unwrap_or_default();
329
330 let import_count = imported_by.len();
331 let used_in_scripts = script_used_packages.contains(package_name);
332 DependencyTrace {
333 package_name: package_name.to_string(),
334 imported_by,
335 type_only_imported_by,
336 used_in_scripts,
337 is_used: import_count > 0 || used_in_scripts,
338 import_count,
339 }
340}
341
342fn format_reference_kind(kind: ReferenceKind) -> String {
343 match kind {
344 ReferenceKind::NamedImport => "named import".to_string(),
345 ReferenceKind::DefaultImport => "default import".to_string(),
346 ReferenceKind::NamespaceImport => "namespace import".to_string(),
347 ReferenceKind::ReExport => "re-export".to_string(),
348 ReferenceKind::DynamicImport => "dynamic import".to_string(),
349 ReferenceKind::SideEffectImport => "side-effect import".to_string(),
350 }
351}
352
353#[must_use]
360pub fn trace_impact_closure(
361 graph: &ModuleGraph,
362 root: &Path,
363 file_path: &str,
364) -> Option<ImpactClosureTrace> {
365 let module = graph
366 .modules
367 .iter()
368 .find(|m| path_matches(&m.path, root, file_path))?;
369
370 let closure = graph.impact_closure(&[module.file_id]);
371 let paths = graph.closure_with_paths(&closure, root);
372
373 let seed = paths
374 .in_diff
375 .first()
376 .cloned()
377 .unwrap_or_else(|| file_path.replace('\\', "/"));
378
379 let coordination_gap = paths
380 .coordination_gap
381 .into_iter()
382 .map(|gap| ImpactClosureGap {
383 consumer_file: gap.consumer_file,
384 consumed_symbols: gap.consumed_symbols,
385 note: "syntactic attention pointer, not a correctness proof".to_string(),
386 })
387 .collect();
388
389 Some(ImpactClosureTrace {
390 seed,
391 affected_not_shown: paths.affected_not_shown,
392 coordination_gap,
393 })
394}
395
396fn build_traced_group(
400 group: &CloneGroup,
401 root: &Path,
402 fingerprints: &CloneFingerprintSet,
403) -> TracedCloneGroup {
404 TracedCloneGroup {
405 fingerprint: fingerprints.fingerprint_for_group(group),
406 token_count: group.token_count,
407 line_count: group.line_count,
408 instances: group
409 .instances
410 .iter()
411 .map(|inst| relativize_instance(inst, root))
412 .collect(),
413 suggestion: group_refactoring_suggestion(group),
414 suggested_name: dominant_identifier(group),
415 }
416}
417
418#[must_use]
419pub fn trace_clone(
420 report: &DuplicationReport,
421 root: &Path,
422 file_path: &str,
423 line: usize,
424) -> CloneTrace {
425 let resolved = root.join(file_path);
426 let mut matched_instance = None;
427 let mut clone_groups = Vec::new();
428 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
429
430 for group in &report.clone_groups {
431 let matching = group.instances.iter().find(|inst| {
432 let inst_matches = inst.file == resolved
433 || inst.file.strip_prefix(root).unwrap_or(&inst.file) == Path::new(file_path);
434 inst_matches && inst.start_line <= line && line <= inst.end_line
435 });
436
437 if let Some(matched) = matching {
438 if matched_instance.is_none() {
439 matched_instance = Some(relativize_instance(matched, root));
440 }
441 clone_groups.push(build_traced_group(group, root, &fingerprints));
442 }
443 }
444
445 CloneTrace {
446 file: PathBuf::from(file_path),
447 line,
448 matched_instance,
449 clone_groups,
450 }
451}
452
453#[must_use]
463pub fn trace_clone_by_fingerprint(
464 report: &DuplicationReport,
465 root: &Path,
466 fingerprint: &str,
467) -> CloneTrace {
468 let fingerprints = CloneFingerprintSet::from_groups(&report.clone_groups);
469 let matched = fingerprints.find_group(&report.clone_groups, fingerprint);
470
471 let Some(group) = matched else {
472 return CloneTrace {
473 file: PathBuf::new(),
474 line: 0,
475 matched_instance: None,
476 clone_groups: Vec::new(),
477 };
478 };
479
480 let representative = group
481 .instances
482 .first()
483 .map(|inst| relativize_instance(inst, root));
484 let (file, line) = representative.as_ref().map_or_else(
485 || (PathBuf::new(), 0),
486 |inst| (inst.file.clone(), inst.start_line),
487 );
488
489 CloneTrace {
490 file,
491 line,
492 matched_instance: representative,
493 clone_groups: vec![build_traced_group(group, root, &fingerprints)],
494 }
495}
496
497fn relativize_instance(inst: &CloneInstance, root: &Path) -> CloneInstance {
501 let rel = inst.file.strip_prefix(root).map_or_else(
502 |_| inst.file.clone(),
503 |p| PathBuf::from(p.to_string_lossy().replace('\\', "/")),
504 );
505 CloneInstance {
506 file: rel,
507 ..inst.clone()
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514
515 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
516 use crate::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
517 use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
518
519 fn build_test_graph() -> ModuleGraph {
520 let files = vec![
521 DiscoveredFile {
522 id: FileId(0),
523 path: PathBuf::from("/project/src/entry.ts"),
524 size_bytes: 100,
525 },
526 DiscoveredFile {
527 id: FileId(1),
528 path: PathBuf::from("/project/src/utils.ts"),
529 size_bytes: 50,
530 },
531 DiscoveredFile {
532 id: FileId(2),
533 path: PathBuf::from("/project/src/unused.ts"),
534 size_bytes: 30,
535 },
536 ];
537
538 let entry_points = vec![EntryPoint {
539 path: PathBuf::from("/project/src/entry.ts"),
540 source: EntryPointSource::PackageJsonMain,
541 }];
542
543 let resolved_modules = vec![
544 ResolvedModule {
545 file_id: FileId(0),
546 path: PathBuf::from("/project/src/entry.ts"),
547 resolved_imports: vec![ResolvedImport {
548 info: ImportInfo {
549 source: "./utils".to_string(),
550 imported_name: ImportedName::Named("foo".to_string()),
551 local_name: "foo".to_string(),
552 is_type_only: false,
553 from_style: false,
554 span: oxc_span::Span::new(0, 10),
555 source_span: oxc_span::Span::default(),
556 },
557 target: ResolveResult::InternalModule(FileId(1)),
558 }],
559 ..Default::default()
560 },
561 ResolvedModule {
562 file_id: FileId(1),
563 path: PathBuf::from("/project/src/utils.ts"),
564 exports: vec![
565 ExportInfo {
566 name: ExportName::Named("foo".to_string()),
567 local_name: Some("foo".to_string()),
568 is_type_only: false,
569 visibility: VisibilityTag::None,
570 expected_unused_reason: None,
571 span: oxc_span::Span::new(0, 20),
572 members: vec![],
573 is_side_effect_used: false,
574 super_class: None,
575 },
576 ExportInfo {
577 name: ExportName::Named("bar".to_string()),
578 local_name: Some("bar".to_string()),
579 is_type_only: false,
580 visibility: VisibilityTag::None,
581 expected_unused_reason: None,
582 span: oxc_span::Span::new(21, 40),
583 members: vec![],
584 is_side_effect_used: false,
585 super_class: None,
586 },
587 ],
588 ..Default::default()
589 },
590 ResolvedModule {
591 file_id: FileId(2),
592 path: PathBuf::from("/project/src/unused.ts"),
593 exports: vec![ExportInfo {
594 name: ExportName::Named("baz".to_string()),
595 local_name: Some("baz".to_string()),
596 is_type_only: false,
597 visibility: VisibilityTag::None,
598 expected_unused_reason: None,
599 span: oxc_span::Span::new(0, 15),
600 members: vec![],
601 is_side_effect_used: false,
602 super_class: None,
603 }],
604 ..Default::default()
605 },
606 ];
607
608 ModuleGraph::build(&resolved_modules, &entry_points, &files)
609 }
610
611 #[test]
612 fn trace_used_export() {
613 let graph = build_test_graph();
614 let root = Path::new("/project");
615
616 let trace = trace_export(&graph, root, "src/utils.ts", "foo").unwrap();
617 assert!(trace.is_used);
618 assert!(trace.file_reachable);
619 assert_eq!(trace.direct_references.len(), 1);
620 assert_eq!(
621 trace.direct_references[0].from_file,
622 PathBuf::from("src/entry.ts")
623 );
624 assert_eq!(trace.direct_references[0].kind, "named import");
625 }
626
627 #[test]
628 fn trace_unused_export() {
629 let graph = build_test_graph();
630 let root = Path::new("/project");
631
632 let trace = trace_export(&graph, root, "src/utils.ts", "bar").unwrap();
633 assert!(!trace.is_used);
634 assert!(trace.file_reachable);
635 assert!(trace.direct_references.is_empty());
636 }
637
638 #[test]
639 fn trace_unreachable_file_export() {
640 let graph = build_test_graph();
641 let root = Path::new("/project");
642
643 let trace = trace_export(&graph, root, "src/unused.ts", "baz").unwrap();
644 assert!(!trace.is_used);
645 assert!(!trace.file_reachable);
646 assert!(trace.reason.contains("unreachable"));
647 }
648
649 #[test]
650 fn trace_nonexistent_export() {
651 let graph = build_test_graph();
652 let root = Path::new("/project");
653
654 let trace = trace_export(&graph, root, "src/utils.ts", "nonexistent");
655 assert!(trace.is_none());
656 }
657
658 #[test]
659 fn trace_nonexistent_file() {
660 let graph = build_test_graph();
661 let root = Path::new("/project");
662
663 let trace = trace_export(&graph, root, "src/nope.ts", "foo");
664 assert!(trace.is_none());
665 }
666
667 #[test]
668 fn trace_file_edges() {
669 let graph = build_test_graph();
670 let root = Path::new("/project");
671
672 let trace = trace_file(&graph, root, "src/entry.ts").unwrap();
673 assert!(trace.is_entry_point);
674 assert!(trace.is_reachable);
675 assert_eq!(trace.imports_from.len(), 1);
676 assert_eq!(trace.imports_from[0], PathBuf::from("src/utils.ts"));
677 assert!(trace.imported_by.is_empty());
678 }
679
680 #[test]
681 fn trace_file_imported_by() {
682 let graph = build_test_graph();
683 let root = Path::new("/project");
684
685 let trace = trace_file(&graph, root, "src/utils.ts").unwrap();
686 assert!(!trace.is_entry_point);
687 assert!(trace.is_reachable);
688 assert_eq!(trace.exports.len(), 2);
689 assert_eq!(trace.imported_by.len(), 1);
690 assert_eq!(trace.imported_by[0], PathBuf::from("src/entry.ts"));
691 }
692
693 #[test]
694 fn trace_unreachable_file() {
695 let graph = build_test_graph();
696 let root = Path::new("/project");
697
698 let trace = trace_file(&graph, root, "src/unused.ts").unwrap();
699 assert!(!trace.is_reachable);
700 assert!(!trace.is_entry_point);
701 assert!(trace.imported_by.is_empty());
702 }
703
704 #[test]
705 fn trace_dependency_used() {
706 let files = vec![DiscoveredFile {
707 id: FileId(0),
708 path: PathBuf::from("/project/src/app.ts"),
709 size_bytes: 100,
710 }];
711 let entry_points = vec![EntryPoint {
712 path: PathBuf::from("/project/src/app.ts"),
713 source: EntryPointSource::PackageJsonMain,
714 }];
715 let resolved_modules = vec![ResolvedModule {
716 file_id: FileId(0),
717 path: PathBuf::from("/project/src/app.ts"),
718 resolved_imports: vec![ResolvedImport {
719 info: ImportInfo {
720 source: "lodash".to_string(),
721 imported_name: ImportedName::Named("get".to_string()),
722 local_name: "get".to_string(),
723 is_type_only: false,
724 from_style: false,
725 span: oxc_span::Span::new(0, 10),
726 source_span: oxc_span::Span::default(),
727 },
728 target: ResolveResult::NpmPackage("lodash".to_string()),
729 }],
730 ..Default::default()
731 }];
732
733 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
734 let root = Path::new("/project");
735
736 let trace = trace_dependency(&graph, root, "lodash", &FxHashSet::default());
737 assert!(trace.is_used);
738 assert!(!trace.used_in_scripts);
739 assert_eq!(trace.import_count, 1);
740 assert_eq!(trace.imported_by[0], PathBuf::from("src/app.ts"));
741 }
742
743 #[test]
744 fn trace_dependency_unused() {
745 let files = vec![DiscoveredFile {
746 id: FileId(0),
747 path: PathBuf::from("/project/src/app.ts"),
748 size_bytes: 100,
749 }];
750 let entry_points = vec![EntryPoint {
751 path: PathBuf::from("/project/src/app.ts"),
752 source: EntryPointSource::PackageJsonMain,
753 }];
754 let resolved_modules = vec![ResolvedModule {
755 file_id: FileId(0),
756 path: PathBuf::from("/project/src/app.ts"),
757 ..Default::default()
758 }];
759
760 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
761 let root = Path::new("/project");
762
763 let trace = trace_dependency(&graph, root, "nonexistent-pkg", &FxHashSet::default());
764 assert!(!trace.is_used);
765 assert!(!trace.used_in_scripts);
766 assert_eq!(trace.import_count, 0);
767 assert!(trace.imported_by.is_empty());
768 }
769
770 #[test]
771 fn trace_dependency_used_only_in_scripts() {
772 let files = vec![DiscoveredFile {
773 id: FileId(0),
774 path: PathBuf::from("/project/src/app.ts"),
775 size_bytes: 100,
776 }];
777 let entry_points = vec![EntryPoint {
778 path: PathBuf::from("/project/src/app.ts"),
779 source: EntryPointSource::PackageJsonMain,
780 }];
781 let resolved_modules = vec![ResolvedModule {
782 file_id: FileId(0),
783 path: PathBuf::from("/project/src/app.ts"),
784 ..Default::default()
785 }];
786
787 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
788 let root = Path::new("/project");
789 let mut script_used = FxHashSet::default();
790 script_used.insert("microbundle".to_string());
791
792 let trace = trace_dependency(&graph, root, "microbundle", &script_used);
793 assert!(
794 trace.is_used,
795 "is_used must be true when the package is referenced from package.json scripts"
796 );
797 assert!(trace.used_in_scripts);
798 assert_eq!(trace.import_count, 0);
799 assert!(trace.imported_by.is_empty());
800 }
801
802 #[test]
803 fn trace_clone_finds_matching_group() {
804 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
805 let report = DuplicationReport {
806 clone_groups: vec![CloneGroup {
807 instances: vec![
808 CloneInstance {
809 file: PathBuf::from("/project/src/a.ts"),
810 start_line: 10,
811 end_line: 20,
812 start_col: 0,
813 end_col: 0,
814 fragment: "fn foo() {}".to_string(),
815 },
816 CloneInstance {
817 file: PathBuf::from("/project/src/b.ts"),
818 start_line: 5,
819 end_line: 15,
820 start_col: 0,
821 end_col: 0,
822 fragment: "fn foo() {}".to_string(),
823 },
824 ],
825 token_count: 60,
826 line_count: 11,
827 }],
828 clone_families: vec![],
829 mirrored_directories: vec![],
830 stats: DuplicationStats {
831 total_files: 2,
832 files_with_clones: 2,
833 total_lines: 100,
834 duplicated_lines: 22,
835 total_tokens: 200,
836 duplicated_tokens: 120,
837 clone_groups: 1,
838 clone_instances: 2,
839 duplication_percentage: 22.0,
840 clone_groups_below_min_occurrences: 0,
841 },
842 };
843 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 15);
844 assert!(trace.matched_instance.is_some());
845 assert_eq!(trace.clone_groups.len(), 1);
846 assert_eq!(trace.clone_groups[0].instances.len(), 2);
847 assert!(trace.clone_groups[0].fingerprint.starts_with("dup:"));
848 assert_eq!(trace.clone_groups[0].suggestion.estimated_savings, 11);
849 }
850
851 #[test]
852 fn trace_clone_by_fingerprint_resolves_and_misses() {
853 use crate::duplicates::{
854 CloneGroup, CloneInstance, DuplicationReport, DuplicationStats, clone_fingerprint,
855 };
856 let report = DuplicationReport {
857 clone_groups: vec![CloneGroup {
858 instances: vec![
859 CloneInstance {
860 file: PathBuf::from("/project/src/a.ts"),
861 start_line: 10,
862 end_line: 20,
863 start_col: 0,
864 end_col: 0,
865 fragment: "fn buildInvoice() {}".to_string(),
866 },
867 CloneInstance {
868 file: PathBuf::from("/project/src/b.ts"),
869 start_line: 5,
870 end_line: 15,
871 start_col: 0,
872 end_col: 0,
873 fragment: "fn buildInvoice() {}".to_string(),
874 },
875 ],
876 token_count: 60,
877 line_count: 11,
878 }],
879 clone_families: vec![],
880 mirrored_directories: vec![],
881 stats: DuplicationStats::default(),
882 };
883 let fp = clone_fingerprint(&report.clone_groups[0].instances);
884
885 let hit = trace_clone_by_fingerprint(&report, Path::new("/project"), &fp);
886 assert!(hit.matched_instance.is_some());
887 assert_eq!(hit.clone_groups.len(), 1);
888 assert_eq!(hit.clone_groups[0].fingerprint, fp);
889 assert_eq!(hit.line, 10);
890
891 let miss = trace_clone_by_fingerprint(&report, Path::new("/project"), "dup:deadbeef");
892 assert!(miss.matched_instance.is_none());
893 assert!(miss.clone_groups.is_empty());
894 }
895
896 #[test]
897 fn trace_clone_no_match() {
898 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
899 let report = DuplicationReport {
900 clone_groups: vec![CloneGroup {
901 instances: vec![CloneInstance {
902 file: PathBuf::from("/project/src/a.ts"),
903 start_line: 10,
904 end_line: 20,
905 start_col: 0,
906 end_col: 0,
907 fragment: "fn foo() {}".to_string(),
908 }],
909 token_count: 60,
910 line_count: 11,
911 }],
912 clone_families: vec![],
913 mirrored_directories: vec![],
914 stats: DuplicationStats {
915 total_files: 1,
916 files_with_clones: 1,
917 total_lines: 50,
918 duplicated_lines: 11,
919 total_tokens: 100,
920 duplicated_tokens: 60,
921 clone_groups: 1,
922 clone_instances: 1,
923 duplication_percentage: 22.0,
924 clone_groups_below_min_occurrences: 0,
925 },
926 };
927 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 25);
928 assert!(trace.matched_instance.is_none());
929 assert!(trace.clone_groups.is_empty());
930 }
931
932 #[test]
933 fn trace_clone_line_boundary() {
934 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
935 let report = DuplicationReport {
936 clone_groups: vec![CloneGroup {
937 instances: vec![
938 CloneInstance {
939 file: PathBuf::from("/project/src/a.ts"),
940 start_line: 10,
941 end_line: 20,
942 start_col: 0,
943 end_col: 0,
944 fragment: "code".to_string(),
945 },
946 CloneInstance {
947 file: PathBuf::from("/project/src/b.ts"),
948 start_line: 1,
949 end_line: 11,
950 start_col: 0,
951 end_col: 0,
952 fragment: "code".to_string(),
953 },
954 ],
955 token_count: 50,
956 line_count: 11,
957 }],
958 clone_families: vec![],
959 mirrored_directories: vec![],
960 stats: DuplicationStats {
961 total_files: 2,
962 files_with_clones: 2,
963 total_lines: 100,
964 duplicated_lines: 22,
965 total_tokens: 200,
966 duplicated_tokens: 100,
967 clone_groups: 1,
968 clone_instances: 2,
969 duplication_percentage: 22.0,
970 clone_groups_below_min_occurrences: 0,
971 },
972 };
973 let root = Path::new("/project");
974 assert!(
975 trace_clone(&report, root, "src/a.ts", 10)
976 .matched_instance
977 .is_some()
978 );
979 assert!(
980 trace_clone(&report, root, "src/a.ts", 20)
981 .matched_instance
982 .is_some()
983 );
984 assert!(
985 trace_clone(&report, root, "src/a.ts", 21)
986 .matched_instance
987 .is_none()
988 );
989 }
990
991 #[test]
992 fn trace_clone_returns_relative_instance_paths() {
993 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
994 let report = DuplicationReport {
995 clone_groups: vec![CloneGroup {
996 instances: vec![
997 CloneInstance {
998 file: PathBuf::from("/project/src/a.ts"),
999 start_line: 1,
1000 end_line: 10,
1001 start_col: 0,
1002 end_col: 0,
1003 fragment: "code".to_string(),
1004 },
1005 CloneInstance {
1006 file: PathBuf::from("/project/src/b.ts"),
1007 start_line: 1,
1008 end_line: 10,
1009 start_col: 0,
1010 end_col: 0,
1011 fragment: "code".to_string(),
1012 },
1013 ],
1014 token_count: 50,
1015 line_count: 10,
1016 }],
1017 clone_families: vec![],
1018 mirrored_directories: vec![],
1019 stats: DuplicationStats {
1020 total_files: 2,
1021 files_with_clones: 2,
1022 total_lines: 50,
1023 duplicated_lines: 20,
1024 total_tokens: 100,
1025 duplicated_tokens: 100,
1026 clone_groups: 1,
1027 clone_instances: 2,
1028 duplication_percentage: 40.0,
1029 clone_groups_below_min_occurrences: 0,
1030 },
1031 };
1032 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 5);
1033 let matched = trace.matched_instance.as_ref().expect("match expected");
1034 assert_eq!(matched.file, PathBuf::from("src/a.ts"));
1035 for group in &trace.clone_groups {
1036 for inst in &group.instances {
1037 let as_str = inst.file.to_string_lossy();
1038 assert!(
1039 !as_str.starts_with('/'),
1040 "instance file should be relative, got {as_str}",
1041 );
1042 assert!(
1043 !as_str.contains(":\\") && !as_str.contains(":/"),
1044 "instance file should not have a drive letter, got {as_str}",
1045 );
1046 }
1047 }
1048
1049 let json = serde_json::to_string(&trace).expect("serializes");
1050 assert!(
1051 !json.contains("\"/project/"),
1052 "serialized trace should not leak absolute paths: {json}",
1053 );
1054 }
1055
1056 #[test]
1063 fn path_matches_normalises_windows_module_path_against_posix_user_path() {
1064 let root = Path::new(r"D:\a\fallow\fallow\tests\fixtures\basic-project");
1065 let module_path =
1066 PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
1067 assert!(path_matches(&module_path, root, "src/utils.ts"));
1068 assert!(path_matches(&module_path, root, r"src\utils.ts"));
1069 }
1070
1071 #[test]
1072 fn path_matches_ends_with_fallback_handles_mixed_separators() {
1073 let root = Path::new("/some/other/root");
1074 let module_path =
1075 PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
1076 assert!(path_matches(&module_path, root, "src/utils.ts"));
1077 }
1078
1079 #[test]
1088 fn export_trace_serializes_windows_path_with_forward_slashes() {
1089 let trace = ExportTrace {
1090 file: PathBuf::from(r"src\utils.ts"),
1091 export_name: "foo".to_string(),
1092 file_reachable: true,
1093 is_entry_point: false,
1094 is_used: true,
1095 direct_references: vec![ExportReference {
1096 from_file: PathBuf::from(r"src\entry.ts"),
1097 kind: "named import".to_string(),
1098 }],
1099 re_export_chains: vec![ReExportChain {
1100 barrel_file: PathBuf::from(r"src\index.ts"),
1101 exported_as: "foo".to_string(),
1102 reference_count: 1,
1103 }],
1104 reason: "ok".to_string(),
1105 };
1106 let json = serde_json::to_string(&trace).expect("serializes");
1107 assert!(
1108 json.contains("\"file\":\"src/utils.ts\""),
1109 "ExportTrace.file must serialize with forward slashes: {json}"
1110 );
1111 assert!(
1112 json.contains("\"from_file\":\"src/entry.ts\""),
1113 "ExportReference.from_file must serialize with forward slashes: {json}"
1114 );
1115 assert!(
1116 json.contains("\"barrel_file\":\"src/index.ts\""),
1117 "ReExportChain.barrel_file must serialize with forward slashes: {json}"
1118 );
1119 assert!(
1120 !json.contains(r"\\"),
1121 "no backslash sequence should remain anywhere in the JSON: {json}"
1122 );
1123 }
1124
1125 #[test]
1126 fn file_trace_serializes_windows_paths_with_forward_slashes() {
1127 let trace = FileTrace {
1128 file: PathBuf::from(r"src\utils.ts"),
1129 is_reachable: true,
1130 is_entry_point: false,
1131 exports: vec![],
1132 imports_from: vec![PathBuf::from(r"src\helpers.ts")],
1133 imported_by: vec![PathBuf::from(r"src\entry.ts")],
1134 re_exports: vec![TracedReExport {
1135 source_file: PathBuf::from(r"src\source.ts"),
1136 imported_name: "foo".to_string(),
1137 exported_name: "foo".to_string(),
1138 }],
1139 };
1140 let json = serde_json::to_string(&trace).expect("serializes");
1141 assert!(json.contains("\"file\":\"src/utils.ts\""), "got {json}");
1142 assert!(
1143 json.contains("\"imports_from\":[\"src/helpers.ts\"]"),
1144 "got {json}"
1145 );
1146 assert!(
1147 json.contains("\"imported_by\":[\"src/entry.ts\"]"),
1148 "got {json}"
1149 );
1150 assert!(
1151 json.contains("\"source_file\":\"src/source.ts\""),
1152 "got {json}"
1153 );
1154 assert!(!json.contains(r"\\"), "no backslash should remain: {json}");
1155 }
1156}