1use std::path::{Path, PathBuf};
2
3use fallow_types::serde_path;
4use rustc_hash::FxHashSet;
5use serde::Serialize;
6
7use crate::duplicates::{CloneInstance, DuplicationReport};
8use crate::graph::{ModuleGraph, ReferenceKind};
9
10fn path_matches(module_path: &Path, root: &Path, user_path: &str) -> bool {
15 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
38#[derive(Debug, Serialize)]
40pub struct ExportTrace {
41 #[serde(serialize_with = "serde_path::serialize")]
43 pub file: PathBuf,
44 pub export_name: String,
46 pub file_reachable: bool,
48 pub is_entry_point: bool,
50 pub is_used: bool,
52 pub direct_references: Vec<ExportReference>,
54 pub re_export_chains: Vec<ReExportChain>,
56 pub reason: String,
58}
59
60#[derive(Debug, Serialize)]
62pub struct ExportReference {
63 #[serde(serialize_with = "serde_path::serialize")]
64 pub from_file: PathBuf,
65 pub kind: String,
66}
67
68#[derive(Debug, Serialize)]
70pub struct ReExportChain {
71 #[serde(serialize_with = "serde_path::serialize")]
73 pub barrel_file: PathBuf,
74 pub exported_as: String,
76 pub reference_count: usize,
78}
79
80#[derive(Debug, Serialize)]
82pub struct FileTrace {
83 #[serde(serialize_with = "serde_path::serialize")]
85 pub file: PathBuf,
86 pub is_reachable: bool,
88 pub is_entry_point: bool,
90 pub exports: Vec<TracedExport>,
92 #[serde(serialize_with = "serde_path::serialize_vec")]
94 pub imports_from: Vec<PathBuf>,
95 #[serde(serialize_with = "serde_path::serialize_vec")]
97 pub imported_by: Vec<PathBuf>,
98 pub re_exports: Vec<TracedReExport>,
100}
101
102#[derive(Debug, Serialize)]
104pub struct TracedExport {
105 pub name: String,
106 pub is_type_only: bool,
107 pub reference_count: usize,
108 pub referenced_by: Vec<ExportReference>,
109}
110
111#[derive(Debug, Serialize)]
113pub struct TracedReExport {
114 #[serde(serialize_with = "serde_path::serialize")]
115 pub source_file: PathBuf,
116 pub imported_name: String,
117 pub exported_name: String,
118}
119
120#[derive(Debug, Serialize)]
122pub struct DependencyTrace {
123 pub package_name: String,
125 #[serde(serialize_with = "serde_path::serialize_vec")]
127 pub imported_by: Vec<PathBuf>,
128 #[serde(serialize_with = "serde_path::serialize_vec")]
130 pub type_only_imported_by: Vec<PathBuf>,
131 pub used_in_scripts: bool,
136 pub is_used: bool,
138 pub import_count: usize,
140}
141
142#[derive(Debug, Clone, Serialize)]
144pub struct PipelineTimings {
145 pub discover_files_ms: f64,
146 pub file_count: usize,
147 pub workspaces_ms: f64,
148 pub workspace_count: usize,
149 pub plugins_ms: f64,
150 pub script_analysis_ms: f64,
151 pub parse_extract_ms: f64,
152 pub parse_cpu_ms: f64,
158 pub module_count: usize,
159 pub cache_hits: usize,
161 pub cache_misses: usize,
163 pub cache_update_ms: f64,
164 pub entry_points_ms: f64,
165 pub entry_point_count: usize,
166 pub resolve_imports_ms: f64,
167 pub build_graph_ms: f64,
168 pub analyze_ms: f64,
169 #[serde(skip_serializing_if = "Option::is_none")]
170 pub duplication_ms: Option<f64>,
171 pub total_ms: f64,
172}
173
174#[must_use]
176pub fn trace_export(
177 graph: &ModuleGraph,
178 root: &Path,
179 file_path: &str,
180 export_name: &str,
181) -> Option<ExportTrace> {
182 let module = graph
184 .modules
185 .iter()
186 .find(|m| path_matches(&m.path, root, file_path))?;
187
188 let export = module.exports.iter().find(|e| {
190 let name_str = e.name.to_string();
191 name_str == export_name || (export_name == "default" && name_str == "default")
192 })?;
193
194 let direct_references: Vec<ExportReference> = export
195 .references
196 .iter()
197 .map(|r| {
198 let from_path = graph.modules.get(r.from_file.0 as usize).map_or_else(
199 || PathBuf::from(format!("<unknown:{}>", r.from_file.0)),
200 |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
201 );
202 ExportReference {
203 from_file: from_path,
204 kind: format_reference_kind(r.kind),
205 }
206 })
207 .collect();
208
209 let re_export_chains: Vec<ReExportChain> = graph
211 .modules
212 .iter()
213 .flat_map(|m| {
214 m.re_exports
215 .iter()
216 .filter(|re| {
217 re.source_file == module.file_id
218 && (re.imported_name == export_name || re.imported_name == "*")
219 })
220 .map(|re| {
221 let barrel_export = m.exports.iter().find(|e| {
222 if re.exported_name == "*" {
223 e.name.to_string() == export_name
224 } else {
225 e.name.to_string() == re.exported_name
226 }
227 });
228 ReExportChain {
229 barrel_file: m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
230 exported_as: re.exported_name.clone(),
231 reference_count: barrel_export.map_or(0, |e| e.references.len()),
232 }
233 })
234 })
235 .collect();
236
237 let is_used = !export.references.is_empty();
238 let reason = if !module.is_reachable() {
239 "File is unreachable from any entry point".to_string()
240 } else if is_used {
241 format!(
242 "Used by {} file(s){}",
243 export.references.len(),
244 if re_export_chains.is_empty() {
245 String::new()
246 } else {
247 format!(", re-exported through {} barrel(s)", re_export_chains.len())
248 }
249 )
250 } else if module.is_entry_point() {
251 "No internal references, but file is an entry point (export is externally accessible)"
252 .to_string()
253 } else if !re_export_chains.is_empty() {
254 format!(
255 "Re-exported through {} barrel(s) but no consumer imports it through the barrel",
256 re_export_chains.len()
257 )
258 } else {
259 "No references found, export is unused".to_string()
260 };
261
262 Some(ExportTrace {
263 file: module
264 .path
265 .strip_prefix(root)
266 .unwrap_or(&module.path)
267 .to_path_buf(),
268 export_name: export_name.to_string(),
269 file_reachable: module.is_reachable(),
270 is_entry_point: module.is_entry_point(),
271 is_used,
272 direct_references,
273 re_export_chains,
274 reason,
275 })
276}
277
278#[must_use]
280pub fn trace_file(graph: &ModuleGraph, root: &Path, file_path: &str) -> Option<FileTrace> {
281 let module = graph
282 .modules
283 .iter()
284 .find(|m| path_matches(&m.path, root, file_path))?;
285
286 let exports: Vec<TracedExport> = module
287 .exports
288 .iter()
289 .map(|e| TracedExport {
290 name: e.name.to_string(),
291 is_type_only: e.is_type_only,
292 reference_count: e.references.len(),
293 referenced_by: e
294 .references
295 .iter()
296 .map(|r| {
297 let from_path = graph.modules.get(r.from_file.0 as usize).map_or_else(
298 || PathBuf::from(format!("<unknown:{}>", r.from_file.0)),
299 |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
300 );
301 ExportReference {
302 from_file: from_path,
303 kind: format_reference_kind(r.kind),
304 }
305 })
306 .collect(),
307 })
308 .collect();
309
310 let imports_from: Vec<PathBuf> = graph
312 .edges_for(module.file_id)
313 .iter()
314 .filter_map(|target_id| {
315 graph
316 .modules
317 .get(target_id.0 as usize)
318 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
319 })
320 .collect();
321
322 let imported_by: Vec<PathBuf> = graph
324 .reverse_deps
325 .get(module.file_id.0 as usize)
326 .map(|deps| {
327 deps.iter()
328 .filter_map(|fid| {
329 graph
330 .modules
331 .get(fid.0 as usize)
332 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
333 })
334 .collect()
335 })
336 .unwrap_or_default();
337
338 let re_exports: Vec<TracedReExport> = module
339 .re_exports
340 .iter()
341 .map(|re| {
342 let source_path = graph.modules.get(re.source_file.0 as usize).map_or_else(
343 || PathBuf::from(format!("<unknown:{}>", re.source_file.0)),
344 |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
345 );
346 TracedReExport {
347 source_file: source_path,
348 imported_name: re.imported_name.clone(),
349 exported_name: re.exported_name.clone(),
350 }
351 })
352 .collect();
353
354 Some(FileTrace {
355 file: module
356 .path
357 .strip_prefix(root)
358 .unwrap_or(&module.path)
359 .to_path_buf(),
360 is_reachable: module.is_reachable(),
361 is_entry_point: module.is_entry_point(),
362 exports,
363 imports_from,
364 imported_by,
365 re_exports,
366 })
367}
368
369#[expect(
378 clippy::implicit_hasher,
379 reason = "fallow standardizes on FxHashSet across the workspace"
380)]
381#[must_use]
382pub fn trace_dependency(
383 graph: &ModuleGraph,
384 root: &Path,
385 package_name: &str,
386 script_used_packages: &FxHashSet<String>,
387) -> DependencyTrace {
388 let imported_by: Vec<PathBuf> = graph
389 .package_usage
390 .get(package_name)
391 .map(|ids| {
392 ids.iter()
393 .filter_map(|fid| {
394 graph
395 .modules
396 .get(fid.0 as usize)
397 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
398 })
399 .collect()
400 })
401 .unwrap_or_default();
402
403 let type_only_imported_by: Vec<PathBuf> = graph
404 .type_only_package_usage
405 .get(package_name)
406 .map(|ids| {
407 ids.iter()
408 .filter_map(|fid| {
409 graph
410 .modules
411 .get(fid.0 as usize)
412 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
413 })
414 .collect()
415 })
416 .unwrap_or_default();
417
418 let import_count = imported_by.len();
419 let used_in_scripts = script_used_packages.contains(package_name);
420 DependencyTrace {
421 package_name: package_name.to_string(),
422 imported_by,
423 type_only_imported_by,
424 used_in_scripts,
425 is_used: import_count > 0 || used_in_scripts,
426 import_count,
427 }
428}
429
430fn format_reference_kind(kind: ReferenceKind) -> String {
431 match kind {
432 ReferenceKind::NamedImport => "named import".to_string(),
433 ReferenceKind::DefaultImport => "default import".to_string(),
434 ReferenceKind::NamespaceImport => "namespace import".to_string(),
435 ReferenceKind::ReExport => "re-export".to_string(),
436 ReferenceKind::DynamicImport => "dynamic import".to_string(),
437 ReferenceKind::SideEffectImport => "side-effect import".to_string(),
438 }
439}
440
441#[derive(Debug, Serialize)]
443pub struct CloneTrace {
444 #[serde(serialize_with = "serde_path::serialize")]
445 pub file: PathBuf,
446 pub line: usize,
447 pub matched_instance: Option<CloneInstance>,
448 pub clone_groups: Vec<TracedCloneGroup>,
449}
450
451#[derive(Debug, Serialize)]
452pub struct TracedCloneGroup {
453 pub token_count: usize,
454 pub line_count: usize,
455 pub instances: Vec<CloneInstance>,
456}
457
458#[must_use]
459pub fn trace_clone(
460 report: &DuplicationReport,
461 root: &Path,
462 file_path: &str,
463 line: usize,
464) -> CloneTrace {
465 let resolved = root.join(file_path);
466 let mut matched_instance = None;
467 let mut clone_groups = Vec::new();
468
469 for group in &report.clone_groups {
470 let matching = group.instances.iter().find(|inst| {
471 let inst_matches = inst.file == resolved
472 || inst.file.strip_prefix(root).unwrap_or(&inst.file) == Path::new(file_path);
473 inst_matches && inst.start_line <= line && line <= inst.end_line
474 });
475
476 if let Some(matched) = matching {
477 if matched_instance.is_none() {
478 matched_instance = Some(relativize_instance(matched, root));
479 }
480 clone_groups.push(TracedCloneGroup {
481 token_count: group.token_count,
482 line_count: group.line_count,
483 instances: group
484 .instances
485 .iter()
486 .map(|inst| relativize_instance(inst, root))
487 .collect(),
488 });
489 }
490 }
491
492 CloneTrace {
493 file: PathBuf::from(file_path),
494 line,
495 matched_instance,
496 clone_groups,
497 }
498}
499
500fn relativize_instance(inst: &CloneInstance, root: &Path) -> CloneInstance {
504 let rel = inst.file.strip_prefix(root).map_or_else(
505 |_| inst.file.clone(),
506 |p| PathBuf::from(p.to_string_lossy().replace('\\', "/")),
507 );
508 CloneInstance {
509 file: rel,
510 ..inst.clone()
511 }
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517
518 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
519 use crate::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
520 use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
521
522 fn build_test_graph() -> ModuleGraph {
523 let files = vec![
524 DiscoveredFile {
525 id: FileId(0),
526 path: PathBuf::from("/project/src/entry.ts"),
527 size_bytes: 100,
528 },
529 DiscoveredFile {
530 id: FileId(1),
531 path: PathBuf::from("/project/src/utils.ts"),
532 size_bytes: 50,
533 },
534 DiscoveredFile {
535 id: FileId(2),
536 path: PathBuf::from("/project/src/unused.ts"),
537 size_bytes: 30,
538 },
539 ];
540
541 let entry_points = vec![EntryPoint {
542 path: PathBuf::from("/project/src/entry.ts"),
543 source: EntryPointSource::PackageJsonMain,
544 }];
545
546 let resolved_modules = vec![
547 ResolvedModule {
548 file_id: FileId(0),
549 path: PathBuf::from("/project/src/entry.ts"),
550 resolved_imports: vec![ResolvedImport {
551 info: ImportInfo {
552 source: "./utils".to_string(),
553 imported_name: ImportedName::Named("foo".to_string()),
554 local_name: "foo".to_string(),
555 is_type_only: false,
556 from_style: false,
557 span: oxc_span::Span::new(0, 10),
558 source_span: oxc_span::Span::default(),
559 },
560 target: ResolveResult::InternalModule(FileId(1)),
561 }],
562 ..Default::default()
563 },
564 ResolvedModule {
565 file_id: FileId(1),
566 path: PathBuf::from("/project/src/utils.ts"),
567 exports: vec![
568 ExportInfo {
569 name: ExportName::Named("foo".to_string()),
570 local_name: Some("foo".to_string()),
571 is_type_only: false,
572 visibility: VisibilityTag::None,
573 span: oxc_span::Span::new(0, 20),
574 members: vec![],
575 is_side_effect_used: false,
576 super_class: None,
577 },
578 ExportInfo {
579 name: ExportName::Named("bar".to_string()),
580 local_name: Some("bar".to_string()),
581 is_type_only: false,
582 visibility: VisibilityTag::None,
583 span: oxc_span::Span::new(21, 40),
584 members: vec![],
585 is_side_effect_used: false,
586 super_class: None,
587 },
588 ],
589 ..Default::default()
590 },
591 ResolvedModule {
592 file_id: FileId(2),
593 path: PathBuf::from("/project/src/unused.ts"),
594 exports: vec![ExportInfo {
595 name: ExportName::Named("baz".to_string()),
596 local_name: Some("baz".to_string()),
597 is_type_only: false,
598 visibility: VisibilityTag::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 {
708 id: FileId(0),
709 path: PathBuf::from("/project/src/app.ts"),
710 size_bytes: 100,
711 }];
712 let entry_points = vec![EntryPoint {
713 path: PathBuf::from("/project/src/app.ts"),
714 source: EntryPointSource::PackageJsonMain,
715 }];
716 let resolved_modules = vec![ResolvedModule {
717 file_id: FileId(0),
718 path: PathBuf::from("/project/src/app.ts"),
719 resolved_imports: vec![ResolvedImport {
720 info: ImportInfo {
721 source: "lodash".to_string(),
722 imported_name: ImportedName::Named("get".to_string()),
723 local_name: "get".to_string(),
724 is_type_only: false,
725 from_style: false,
726 span: oxc_span::Span::new(0, 10),
727 source_span: oxc_span::Span::default(),
728 },
729 target: ResolveResult::NpmPackage("lodash".to_string()),
730 }],
731 ..Default::default()
732 }];
733
734 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
735 let root = Path::new("/project");
736
737 let trace = trace_dependency(&graph, root, "lodash", &FxHashSet::default());
738 assert!(trace.is_used);
739 assert!(!trace.used_in_scripts);
740 assert_eq!(trace.import_count, 1);
741 assert_eq!(trace.imported_by[0], PathBuf::from("src/app.ts"));
742 }
743
744 #[test]
745 fn trace_dependency_unused() {
746 let files = vec![DiscoveredFile {
747 id: FileId(0),
748 path: PathBuf::from("/project/src/app.ts"),
749 size_bytes: 100,
750 }];
751 let entry_points = vec![EntryPoint {
752 path: PathBuf::from("/project/src/app.ts"),
753 source: EntryPointSource::PackageJsonMain,
754 }];
755 let resolved_modules = vec![ResolvedModule {
756 file_id: FileId(0),
757 path: PathBuf::from("/project/src/app.ts"),
758 ..Default::default()
759 }];
760
761 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
762 let root = Path::new("/project");
763
764 let trace = trace_dependency(&graph, root, "nonexistent-pkg", &FxHashSet::default());
765 assert!(!trace.is_used);
766 assert!(!trace.used_in_scripts);
767 assert_eq!(trace.import_count, 0);
768 assert!(trace.imported_by.is_empty());
769 }
770
771 #[test]
772 fn trace_dependency_used_only_in_scripts() {
773 let files = vec![DiscoveredFile {
774 id: FileId(0),
775 path: PathBuf::from("/project/src/app.ts"),
776 size_bytes: 100,
777 }];
778 let entry_points = vec![EntryPoint {
779 path: PathBuf::from("/project/src/app.ts"),
780 source: EntryPointSource::PackageJsonMain,
781 }];
782 let resolved_modules = vec![ResolvedModule {
783 file_id: FileId(0),
784 path: PathBuf::from("/project/src/app.ts"),
785 ..Default::default()
786 }];
787
788 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
789 let root = Path::new("/project");
790 let mut script_used = FxHashSet::default();
791 script_used.insert("microbundle".to_string());
792
793 let trace = trace_dependency(&graph, root, "microbundle", &script_used);
794 assert!(
795 trace.is_used,
796 "is_used must be true when the package is referenced from package.json scripts"
797 );
798 assert!(trace.used_in_scripts);
799 assert_eq!(trace.import_count, 0);
800 assert!(trace.imported_by.is_empty());
801 }
802
803 #[test]
804 fn trace_clone_finds_matching_group() {
805 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
806 let report = DuplicationReport {
807 clone_groups: vec![CloneGroup {
808 instances: vec![
809 CloneInstance {
810 file: PathBuf::from("/project/src/a.ts"),
811 start_line: 10,
812 end_line: 20,
813 start_col: 0,
814 end_col: 0,
815 fragment: "fn foo() {}".to_string(),
816 },
817 CloneInstance {
818 file: PathBuf::from("/project/src/b.ts"),
819 start_line: 5,
820 end_line: 15,
821 start_col: 0,
822 end_col: 0,
823 fragment: "fn foo() {}".to_string(),
824 },
825 ],
826 token_count: 60,
827 line_count: 11,
828 }],
829 clone_families: vec![],
830 mirrored_directories: vec![],
831 stats: DuplicationStats {
832 total_files: 2,
833 files_with_clones: 2,
834 total_lines: 100,
835 duplicated_lines: 22,
836 total_tokens: 200,
837 duplicated_tokens: 120,
838 clone_groups: 1,
839 clone_instances: 2,
840 duplication_percentage: 22.0,
841 clone_groups_below_min_occurrences: 0,
842 },
843 };
844 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 15);
845 assert!(trace.matched_instance.is_some());
846 assert_eq!(trace.clone_groups.len(), 1);
847 assert_eq!(trace.clone_groups[0].instances.len(), 2);
848 }
849
850 #[test]
851 fn trace_clone_no_match() {
852 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
853 let report = DuplicationReport {
854 clone_groups: vec![CloneGroup {
855 instances: vec![CloneInstance {
856 file: PathBuf::from("/project/src/a.ts"),
857 start_line: 10,
858 end_line: 20,
859 start_col: 0,
860 end_col: 0,
861 fragment: "fn foo() {}".to_string(),
862 }],
863 token_count: 60,
864 line_count: 11,
865 }],
866 clone_families: vec![],
867 mirrored_directories: vec![],
868 stats: DuplicationStats {
869 total_files: 1,
870 files_with_clones: 1,
871 total_lines: 50,
872 duplicated_lines: 11,
873 total_tokens: 100,
874 duplicated_tokens: 60,
875 clone_groups: 1,
876 clone_instances: 1,
877 duplication_percentage: 22.0,
878 clone_groups_below_min_occurrences: 0,
879 },
880 };
881 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 25);
882 assert!(trace.matched_instance.is_none());
883 assert!(trace.clone_groups.is_empty());
884 }
885
886 #[test]
887 fn trace_clone_line_boundary() {
888 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
889 let report = DuplicationReport {
890 clone_groups: vec![CloneGroup {
891 instances: vec![
892 CloneInstance {
893 file: PathBuf::from("/project/src/a.ts"),
894 start_line: 10,
895 end_line: 20,
896 start_col: 0,
897 end_col: 0,
898 fragment: "code".to_string(),
899 },
900 CloneInstance {
901 file: PathBuf::from("/project/src/b.ts"),
902 start_line: 1,
903 end_line: 11,
904 start_col: 0,
905 end_col: 0,
906 fragment: "code".to_string(),
907 },
908 ],
909 token_count: 50,
910 line_count: 11,
911 }],
912 clone_families: vec![],
913 mirrored_directories: vec![],
914 stats: DuplicationStats {
915 total_files: 2,
916 files_with_clones: 2,
917 total_lines: 100,
918 duplicated_lines: 22,
919 total_tokens: 200,
920 duplicated_tokens: 100,
921 clone_groups: 1,
922 clone_instances: 2,
923 duplication_percentage: 22.0,
924 clone_groups_below_min_occurrences: 0,
925 },
926 };
927 let root = Path::new("/project");
928 assert!(
929 trace_clone(&report, root, "src/a.ts", 10)
930 .matched_instance
931 .is_some()
932 );
933 assert!(
934 trace_clone(&report, root, "src/a.ts", 20)
935 .matched_instance
936 .is_some()
937 );
938 assert!(
939 trace_clone(&report, root, "src/a.ts", 21)
940 .matched_instance
941 .is_none()
942 );
943 }
944
945 #[test]
946 fn trace_clone_returns_relative_instance_paths() {
947 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
948 let report = DuplicationReport {
949 clone_groups: vec![CloneGroup {
950 instances: vec![
951 CloneInstance {
952 file: PathBuf::from("/project/src/a.ts"),
953 start_line: 1,
954 end_line: 10,
955 start_col: 0,
956 end_col: 0,
957 fragment: "code".to_string(),
958 },
959 CloneInstance {
960 file: PathBuf::from("/project/src/b.ts"),
961 start_line: 1,
962 end_line: 10,
963 start_col: 0,
964 end_col: 0,
965 fragment: "code".to_string(),
966 },
967 ],
968 token_count: 50,
969 line_count: 10,
970 }],
971 clone_families: vec![],
972 mirrored_directories: vec![],
973 stats: DuplicationStats {
974 total_files: 2,
975 files_with_clones: 2,
976 total_lines: 50,
977 duplicated_lines: 20,
978 total_tokens: 100,
979 duplicated_tokens: 100,
980 clone_groups: 1,
981 clone_instances: 2,
982 duplication_percentage: 40.0,
983 clone_groups_below_min_occurrences: 0,
984 },
985 };
986 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 5);
987 let matched = trace.matched_instance.as_ref().expect("match expected");
988 assert_eq!(matched.file, PathBuf::from("src/a.ts"));
989 for group in &trace.clone_groups {
990 for inst in &group.instances {
991 let as_str = inst.file.to_string_lossy();
992 assert!(
993 !as_str.starts_with('/'),
994 "instance file should be relative, got {as_str}",
995 );
996 assert!(
997 !as_str.contains(":\\") && !as_str.contains(":/"),
998 "instance file should not have a drive letter, got {as_str}",
999 );
1000 }
1001 }
1002
1003 let json = serde_json::to_string(&trace).expect("serializes");
1004 assert!(
1005 !json.contains("\"/project/"),
1006 "serialized trace should not leak absolute paths: {json}",
1007 );
1008 }
1009
1010 #[test]
1017 fn path_matches_normalises_windows_module_path_against_posix_user_path() {
1018 let root = Path::new(r"D:\a\fallow\fallow\tests\fixtures\basic-project");
1019 let module_path =
1020 PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
1021 assert!(path_matches(&module_path, root, "src/utils.ts"));
1025 assert!(path_matches(&module_path, root, r"src\utils.ts"));
1026 }
1027
1028 #[test]
1029 fn path_matches_ends_with_fallback_handles_mixed_separators() {
1030 let root = Path::new("/some/other/root");
1031 let module_path =
1032 PathBuf::from(r"D:\a\fallow\fallow\tests\fixtures\basic-project\src\utils.ts");
1033 assert!(path_matches(&module_path, root, "src/utils.ts"));
1036 }
1037
1038 #[test]
1047 fn export_trace_serializes_windows_path_with_forward_slashes() {
1048 let trace = ExportTrace {
1049 file: PathBuf::from(r"src\utils.ts"),
1050 export_name: "foo".to_string(),
1051 file_reachable: true,
1052 is_entry_point: false,
1053 is_used: true,
1054 direct_references: vec![ExportReference {
1055 from_file: PathBuf::from(r"src\entry.ts"),
1056 kind: "named import".to_string(),
1057 }],
1058 re_export_chains: vec![ReExportChain {
1059 barrel_file: PathBuf::from(r"src\index.ts"),
1060 exported_as: "foo".to_string(),
1061 reference_count: 1,
1062 }],
1063 reason: "ok".to_string(),
1064 };
1065 let json = serde_json::to_string(&trace).expect("serializes");
1066 assert!(
1067 json.contains("\"file\":\"src/utils.ts\""),
1068 "ExportTrace.file must serialize with forward slashes: {json}"
1069 );
1070 assert!(
1071 json.contains("\"from_file\":\"src/entry.ts\""),
1072 "ExportReference.from_file must serialize with forward slashes: {json}"
1073 );
1074 assert!(
1075 json.contains("\"barrel_file\":\"src/index.ts\""),
1076 "ReExportChain.barrel_file must serialize with forward slashes: {json}"
1077 );
1078 assert!(
1079 !json.contains(r"\\"),
1080 "no backslash sequence should remain anywhere in the JSON: {json}"
1081 );
1082 }
1083
1084 #[test]
1085 fn file_trace_serializes_windows_paths_with_forward_slashes() {
1086 let trace = FileTrace {
1087 file: PathBuf::from(r"src\utils.ts"),
1088 is_reachable: true,
1089 is_entry_point: false,
1090 exports: vec![],
1091 imports_from: vec![PathBuf::from(r"src\helpers.ts")],
1092 imported_by: vec![PathBuf::from(r"src\entry.ts")],
1093 re_exports: vec![TracedReExport {
1094 source_file: PathBuf::from(r"src\source.ts"),
1095 imported_name: "foo".to_string(),
1096 exported_name: "foo".to_string(),
1097 }],
1098 };
1099 let json = serde_json::to_string(&trace).expect("serializes");
1100 assert!(json.contains("\"file\":\"src/utils.ts\""), "got {json}");
1101 assert!(
1102 json.contains("\"imports_from\":[\"src/helpers.ts\"]"),
1103 "got {json}"
1104 );
1105 assert!(
1106 json.contains("\"imported_by\":[\"src/entry.ts\"]"),
1107 "got {json}"
1108 );
1109 assert!(
1110 json.contains("\"source_file\":\"src/source.ts\""),
1111 "got {json}"
1112 );
1113 assert!(!json.contains(r"\\"), "no backslash should remain: {json}");
1114 }
1115}