1use std::path::{Path, PathBuf};
2
3use rustc_hash::FxHashSet;
4use serde::Serialize;
5
6use crate::duplicates::{CloneInstance, DuplicationReport};
7use crate::graph::{ModuleGraph, ReferenceKind};
8
9fn path_matches(module_path: &Path, root: &Path, user_path: &str) -> bool {
14 let rel = module_path.strip_prefix(root).unwrap_or(module_path);
15 let rel_str = rel.to_string_lossy();
16 if rel_str == user_path || module_path.to_string_lossy() == user_path {
17 return true;
18 }
19 if dunce::canonicalize(root).is_ok_and(|canonical_root| {
20 module_path
21 .strip_prefix(&canonical_root)
22 .is_ok_and(|rel| rel.to_string_lossy() == user_path)
23 }) {
24 return true;
25 }
26 let module_str = module_path.to_string_lossy();
27 module_str.ends_with(&format!("/{user_path}"))
28}
29
30#[derive(Debug, Serialize)]
32pub struct ExportTrace {
33 pub file: PathBuf,
35 pub export_name: String,
37 pub file_reachable: bool,
39 pub is_entry_point: bool,
41 pub is_used: bool,
43 pub direct_references: Vec<ExportReference>,
45 pub re_export_chains: Vec<ReExportChain>,
47 pub reason: String,
49}
50
51#[derive(Debug, Serialize)]
53pub struct ExportReference {
54 pub from_file: PathBuf,
55 pub kind: String,
56}
57
58#[derive(Debug, Serialize)]
60pub struct ReExportChain {
61 pub barrel_file: PathBuf,
63 pub exported_as: String,
65 pub reference_count: usize,
67}
68
69#[derive(Debug, Serialize)]
71pub struct FileTrace {
72 pub file: PathBuf,
74 pub is_reachable: bool,
76 pub is_entry_point: bool,
78 pub exports: Vec<TracedExport>,
80 pub imports_from: Vec<PathBuf>,
82 pub imported_by: Vec<PathBuf>,
84 pub re_exports: Vec<TracedReExport>,
86}
87
88#[derive(Debug, Serialize)]
90pub struct TracedExport {
91 pub name: String,
92 pub is_type_only: bool,
93 pub reference_count: usize,
94 pub referenced_by: Vec<ExportReference>,
95}
96
97#[derive(Debug, Serialize)]
99pub struct TracedReExport {
100 pub source_file: PathBuf,
101 pub imported_name: String,
102 pub exported_name: String,
103}
104
105#[derive(Debug, Serialize)]
107pub struct DependencyTrace {
108 pub package_name: String,
110 pub imported_by: Vec<PathBuf>,
112 pub type_only_imported_by: Vec<PathBuf>,
114 pub used_in_scripts: bool,
119 pub is_used: bool,
121 pub import_count: usize,
123}
124
125#[derive(Debug, Clone, Serialize)]
127pub struct PipelineTimings {
128 pub discover_files_ms: f64,
129 pub file_count: usize,
130 pub workspaces_ms: f64,
131 pub workspace_count: usize,
132 pub plugins_ms: f64,
133 pub script_analysis_ms: f64,
134 pub parse_extract_ms: f64,
135 pub module_count: usize,
136 pub cache_hits: usize,
138 pub cache_misses: usize,
140 pub cache_update_ms: f64,
141 pub entry_points_ms: f64,
142 pub entry_point_count: usize,
143 pub resolve_imports_ms: f64,
144 pub build_graph_ms: f64,
145 pub analyze_ms: f64,
146 pub total_ms: f64,
147}
148
149#[must_use]
151pub fn trace_export(
152 graph: &ModuleGraph,
153 root: &Path,
154 file_path: &str,
155 export_name: &str,
156) -> Option<ExportTrace> {
157 let module = graph
159 .modules
160 .iter()
161 .find(|m| path_matches(&m.path, root, file_path))?;
162
163 let export = module.exports.iter().find(|e| {
165 let name_str = e.name.to_string();
166 name_str == export_name || (export_name == "default" && name_str == "default")
167 })?;
168
169 let direct_references: Vec<ExportReference> = export
170 .references
171 .iter()
172 .map(|r| {
173 let from_path = graph.modules.get(r.from_file.0 as usize).map_or_else(
174 || PathBuf::from(format!("<unknown:{}>", r.from_file.0)),
175 |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
176 );
177 ExportReference {
178 from_file: from_path,
179 kind: format_reference_kind(r.kind),
180 }
181 })
182 .collect();
183
184 let re_export_chains: Vec<ReExportChain> = graph
186 .modules
187 .iter()
188 .flat_map(|m| {
189 m.re_exports
190 .iter()
191 .filter(|re| {
192 re.source_file == module.file_id
193 && (re.imported_name == export_name || re.imported_name == "*")
194 })
195 .map(|re| {
196 let barrel_export = m.exports.iter().find(|e| {
197 if re.exported_name == "*" {
198 e.name.to_string() == export_name
199 } else {
200 e.name.to_string() == re.exported_name
201 }
202 });
203 ReExportChain {
204 barrel_file: m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
205 exported_as: re.exported_name.clone(),
206 reference_count: barrel_export.map_or(0, |e| e.references.len()),
207 }
208 })
209 })
210 .collect();
211
212 let is_used = !export.references.is_empty();
213 let reason = if !module.is_reachable() {
214 "File is unreachable from any entry point".to_string()
215 } else if is_used {
216 format!(
217 "Used by {} file(s){}",
218 export.references.len(),
219 if re_export_chains.is_empty() {
220 String::new()
221 } else {
222 format!(", re-exported through {} barrel(s)", re_export_chains.len())
223 }
224 )
225 } else if module.is_entry_point() {
226 "No internal references, but file is an entry point (export is externally accessible)"
227 .to_string()
228 } else if !re_export_chains.is_empty() {
229 format!(
230 "Re-exported through {} barrel(s) but no consumer imports it through the barrel",
231 re_export_chains.len()
232 )
233 } else {
234 "No references found — export is unused".to_string()
235 };
236
237 Some(ExportTrace {
238 file: module
239 .path
240 .strip_prefix(root)
241 .unwrap_or(&module.path)
242 .to_path_buf(),
243 export_name: export_name.to_string(),
244 file_reachable: module.is_reachable(),
245 is_entry_point: module.is_entry_point(),
246 is_used,
247 direct_references,
248 re_export_chains,
249 reason,
250 })
251}
252
253#[must_use]
255pub fn trace_file(graph: &ModuleGraph, root: &Path, file_path: &str) -> Option<FileTrace> {
256 let module = graph
257 .modules
258 .iter()
259 .find(|m| path_matches(&m.path, root, file_path))?;
260
261 let exports: Vec<TracedExport> = module
262 .exports
263 .iter()
264 .map(|e| TracedExport {
265 name: e.name.to_string(),
266 is_type_only: e.is_type_only,
267 reference_count: e.references.len(),
268 referenced_by: e
269 .references
270 .iter()
271 .map(|r| {
272 let from_path = graph.modules.get(r.from_file.0 as usize).map_or_else(
273 || PathBuf::from(format!("<unknown:{}>", r.from_file.0)),
274 |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
275 );
276 ExportReference {
277 from_file: from_path,
278 kind: format_reference_kind(r.kind),
279 }
280 })
281 .collect(),
282 })
283 .collect();
284
285 let imports_from: Vec<PathBuf> = graph
287 .edges_for(module.file_id)
288 .iter()
289 .filter_map(|target_id| {
290 graph
291 .modules
292 .get(target_id.0 as usize)
293 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
294 })
295 .collect();
296
297 let imported_by: Vec<PathBuf> = graph
299 .reverse_deps
300 .get(module.file_id.0 as usize)
301 .map(|deps| {
302 deps.iter()
303 .filter_map(|fid| {
304 graph
305 .modules
306 .get(fid.0 as usize)
307 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
308 })
309 .collect()
310 })
311 .unwrap_or_default();
312
313 let re_exports: Vec<TracedReExport> = module
314 .re_exports
315 .iter()
316 .map(|re| {
317 let source_path = graph.modules.get(re.source_file.0 as usize).map_or_else(
318 || PathBuf::from(format!("<unknown:{}>", re.source_file.0)),
319 |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
320 );
321 TracedReExport {
322 source_file: source_path,
323 imported_name: re.imported_name.clone(),
324 exported_name: re.exported_name.clone(),
325 }
326 })
327 .collect();
328
329 Some(FileTrace {
330 file: module
331 .path
332 .strip_prefix(root)
333 .unwrap_or(&module.path)
334 .to_path_buf(),
335 is_reachable: module.is_reachable(),
336 is_entry_point: module.is_entry_point(),
337 exports,
338 imports_from,
339 imported_by,
340 re_exports,
341 })
342}
343
344#[expect(
353 clippy::implicit_hasher,
354 reason = "fallow standardizes on FxHashSet across the workspace"
355)]
356#[must_use]
357pub fn trace_dependency(
358 graph: &ModuleGraph,
359 root: &Path,
360 package_name: &str,
361 script_used_packages: &FxHashSet<String>,
362) -> DependencyTrace {
363 let imported_by: Vec<PathBuf> = graph
364 .package_usage
365 .get(package_name)
366 .map(|ids| {
367 ids.iter()
368 .filter_map(|fid| {
369 graph
370 .modules
371 .get(fid.0 as usize)
372 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
373 })
374 .collect()
375 })
376 .unwrap_or_default();
377
378 let type_only_imported_by: Vec<PathBuf> = graph
379 .type_only_package_usage
380 .get(package_name)
381 .map(|ids| {
382 ids.iter()
383 .filter_map(|fid| {
384 graph
385 .modules
386 .get(fid.0 as usize)
387 .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
388 })
389 .collect()
390 })
391 .unwrap_or_default();
392
393 let import_count = imported_by.len();
394 let used_in_scripts = script_used_packages.contains(package_name);
395 DependencyTrace {
396 package_name: package_name.to_string(),
397 imported_by,
398 type_only_imported_by,
399 used_in_scripts,
400 is_used: import_count > 0 || used_in_scripts,
401 import_count,
402 }
403}
404
405fn format_reference_kind(kind: ReferenceKind) -> String {
406 match kind {
407 ReferenceKind::NamedImport => "named import".to_string(),
408 ReferenceKind::DefaultImport => "default import".to_string(),
409 ReferenceKind::NamespaceImport => "namespace import".to_string(),
410 ReferenceKind::ReExport => "re-export".to_string(),
411 ReferenceKind::DynamicImport => "dynamic import".to_string(),
412 ReferenceKind::SideEffectImport => "side-effect import".to_string(),
413 }
414}
415
416#[derive(Debug, Serialize)]
418pub struct CloneTrace {
419 pub file: PathBuf,
420 pub line: usize,
421 pub matched_instance: Option<CloneInstance>,
422 pub clone_groups: Vec<TracedCloneGroup>,
423}
424
425#[derive(Debug, Serialize)]
426pub struct TracedCloneGroup {
427 pub token_count: usize,
428 pub line_count: usize,
429 pub instances: Vec<CloneInstance>,
430}
431
432#[must_use]
433pub fn trace_clone(
434 report: &DuplicationReport,
435 root: &Path,
436 file_path: &str,
437 line: usize,
438) -> CloneTrace {
439 let resolved = root.join(file_path);
440 let mut matched_instance = None;
441 let mut clone_groups = Vec::new();
442
443 for group in &report.clone_groups {
444 let matching = group.instances.iter().find(|inst| {
445 let inst_matches = inst.file == resolved
446 || inst.file.strip_prefix(root).unwrap_or(&inst.file) == Path::new(file_path);
447 inst_matches && inst.start_line <= line && line <= inst.end_line
448 });
449
450 if let Some(matched) = matching {
451 if matched_instance.is_none() {
452 matched_instance = Some(relativize_instance(matched, root));
453 }
454 clone_groups.push(TracedCloneGroup {
455 token_count: group.token_count,
456 line_count: group.line_count,
457 instances: group
458 .instances
459 .iter()
460 .map(|inst| relativize_instance(inst, root))
461 .collect(),
462 });
463 }
464 }
465
466 CloneTrace {
467 file: PathBuf::from(file_path),
468 line,
469 matched_instance,
470 clone_groups,
471 }
472}
473
474fn relativize_instance(inst: &CloneInstance, root: &Path) -> CloneInstance {
478 let rel = inst.file.strip_prefix(root).map_or_else(
479 |_| inst.file.clone(),
480 |p| PathBuf::from(p.to_string_lossy().replace('\\', "/")),
481 );
482 CloneInstance {
483 file: rel,
484 ..inst.clone()
485 }
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491
492 use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
493 use crate::extract::{ExportInfo, ExportName, ImportInfo, ImportedName, VisibilityTag};
494 use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
495
496 fn build_test_graph() -> ModuleGraph {
497 let files = vec![
498 DiscoveredFile {
499 id: FileId(0),
500 path: PathBuf::from("/project/src/entry.ts"),
501 size_bytes: 100,
502 },
503 DiscoveredFile {
504 id: FileId(1),
505 path: PathBuf::from("/project/src/utils.ts"),
506 size_bytes: 50,
507 },
508 DiscoveredFile {
509 id: FileId(2),
510 path: PathBuf::from("/project/src/unused.ts"),
511 size_bytes: 30,
512 },
513 ];
514
515 let entry_points = vec![EntryPoint {
516 path: PathBuf::from("/project/src/entry.ts"),
517 source: EntryPointSource::PackageJsonMain,
518 }];
519
520 let resolved_modules = vec![
521 ResolvedModule {
522 file_id: FileId(0),
523 path: PathBuf::from("/project/src/entry.ts"),
524 resolved_imports: vec![ResolvedImport {
525 info: ImportInfo {
526 source: "./utils".to_string(),
527 imported_name: ImportedName::Named("foo".to_string()),
528 local_name: "foo".to_string(),
529 is_type_only: false,
530 from_style: false,
531 span: oxc_span::Span::new(0, 10),
532 source_span: oxc_span::Span::default(),
533 },
534 target: ResolveResult::InternalModule(FileId(1)),
535 }],
536 ..Default::default()
537 },
538 ResolvedModule {
539 file_id: FileId(1),
540 path: PathBuf::from("/project/src/utils.ts"),
541 exports: vec![
542 ExportInfo {
543 name: ExportName::Named("foo".to_string()),
544 local_name: Some("foo".to_string()),
545 is_type_only: false,
546 visibility: VisibilityTag::None,
547 span: oxc_span::Span::new(0, 20),
548 members: vec![],
549 super_class: None,
550 },
551 ExportInfo {
552 name: ExportName::Named("bar".to_string()),
553 local_name: Some("bar".to_string()),
554 is_type_only: false,
555 visibility: VisibilityTag::None,
556 span: oxc_span::Span::new(21, 40),
557 members: vec![],
558 super_class: None,
559 },
560 ],
561 ..Default::default()
562 },
563 ResolvedModule {
564 file_id: FileId(2),
565 path: PathBuf::from("/project/src/unused.ts"),
566 exports: vec![ExportInfo {
567 name: ExportName::Named("baz".to_string()),
568 local_name: Some("baz".to_string()),
569 is_type_only: false,
570 visibility: VisibilityTag::None,
571 span: oxc_span::Span::new(0, 15),
572 members: vec![],
573 super_class: None,
574 }],
575 ..Default::default()
576 },
577 ];
578
579 ModuleGraph::build(&resolved_modules, &entry_points, &files)
580 }
581
582 #[test]
583 fn trace_used_export() {
584 let graph = build_test_graph();
585 let root = Path::new("/project");
586
587 let trace = trace_export(&graph, root, "src/utils.ts", "foo").unwrap();
588 assert!(trace.is_used);
589 assert!(trace.file_reachable);
590 assert_eq!(trace.direct_references.len(), 1);
591 assert_eq!(
592 trace.direct_references[0].from_file,
593 PathBuf::from("src/entry.ts")
594 );
595 assert_eq!(trace.direct_references[0].kind, "named import");
596 }
597
598 #[test]
599 fn trace_unused_export() {
600 let graph = build_test_graph();
601 let root = Path::new("/project");
602
603 let trace = trace_export(&graph, root, "src/utils.ts", "bar").unwrap();
604 assert!(!trace.is_used);
605 assert!(trace.file_reachable);
606 assert!(trace.direct_references.is_empty());
607 }
608
609 #[test]
610 fn trace_unreachable_file_export() {
611 let graph = build_test_graph();
612 let root = Path::new("/project");
613
614 let trace = trace_export(&graph, root, "src/unused.ts", "baz").unwrap();
615 assert!(!trace.is_used);
616 assert!(!trace.file_reachable);
617 assert!(trace.reason.contains("unreachable"));
618 }
619
620 #[test]
621 fn trace_nonexistent_export() {
622 let graph = build_test_graph();
623 let root = Path::new("/project");
624
625 let trace = trace_export(&graph, root, "src/utils.ts", "nonexistent");
626 assert!(trace.is_none());
627 }
628
629 #[test]
630 fn trace_nonexistent_file() {
631 let graph = build_test_graph();
632 let root = Path::new("/project");
633
634 let trace = trace_export(&graph, root, "src/nope.ts", "foo");
635 assert!(trace.is_none());
636 }
637
638 #[test]
639 fn trace_file_edges() {
640 let graph = build_test_graph();
641 let root = Path::new("/project");
642
643 let trace = trace_file(&graph, root, "src/entry.ts").unwrap();
644 assert!(trace.is_entry_point);
645 assert!(trace.is_reachable);
646 assert_eq!(trace.imports_from.len(), 1);
647 assert_eq!(trace.imports_from[0], PathBuf::from("src/utils.ts"));
648 assert!(trace.imported_by.is_empty());
649 }
650
651 #[test]
652 fn trace_file_imported_by() {
653 let graph = build_test_graph();
654 let root = Path::new("/project");
655
656 let trace = trace_file(&graph, root, "src/utils.ts").unwrap();
657 assert!(!trace.is_entry_point);
658 assert!(trace.is_reachable);
659 assert_eq!(trace.exports.len(), 2);
660 assert_eq!(trace.imported_by.len(), 1);
661 assert_eq!(trace.imported_by[0], PathBuf::from("src/entry.ts"));
662 }
663
664 #[test]
665 fn trace_unreachable_file() {
666 let graph = build_test_graph();
667 let root = Path::new("/project");
668
669 let trace = trace_file(&graph, root, "src/unused.ts").unwrap();
670 assert!(!trace.is_reachable);
671 assert!(!trace.is_entry_point);
672 assert!(trace.imported_by.is_empty());
673 }
674
675 #[test]
676 fn trace_dependency_used() {
677 let files = vec![DiscoveredFile {
679 id: FileId(0),
680 path: PathBuf::from("/project/src/app.ts"),
681 size_bytes: 100,
682 }];
683 let entry_points = vec![EntryPoint {
684 path: PathBuf::from("/project/src/app.ts"),
685 source: EntryPointSource::PackageJsonMain,
686 }];
687 let resolved_modules = vec![ResolvedModule {
688 file_id: FileId(0),
689 path: PathBuf::from("/project/src/app.ts"),
690 resolved_imports: vec![ResolvedImport {
691 info: ImportInfo {
692 source: "lodash".to_string(),
693 imported_name: ImportedName::Named("get".to_string()),
694 local_name: "get".to_string(),
695 is_type_only: false,
696 from_style: false,
697 span: oxc_span::Span::new(0, 10),
698 source_span: oxc_span::Span::default(),
699 },
700 target: ResolveResult::NpmPackage("lodash".to_string()),
701 }],
702 ..Default::default()
703 }];
704
705 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
706 let root = Path::new("/project");
707
708 let trace = trace_dependency(&graph, root, "lodash", &FxHashSet::default());
709 assert!(trace.is_used);
710 assert!(!trace.used_in_scripts);
711 assert_eq!(trace.import_count, 1);
712 assert_eq!(trace.imported_by[0], PathBuf::from("src/app.ts"));
713 }
714
715 #[test]
716 fn trace_dependency_unused() {
717 let files = vec![DiscoveredFile {
718 id: FileId(0),
719 path: PathBuf::from("/project/src/app.ts"),
720 size_bytes: 100,
721 }];
722 let entry_points = vec![EntryPoint {
723 path: PathBuf::from("/project/src/app.ts"),
724 source: EntryPointSource::PackageJsonMain,
725 }];
726 let resolved_modules = vec![ResolvedModule {
727 file_id: FileId(0),
728 path: PathBuf::from("/project/src/app.ts"),
729 ..Default::default()
730 }];
731
732 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
733 let root = Path::new("/project");
734
735 let trace = trace_dependency(&graph, root, "nonexistent-pkg", &FxHashSet::default());
736 assert!(!trace.is_used);
737 assert!(!trace.used_in_scripts);
738 assert_eq!(trace.import_count, 0);
739 assert!(trace.imported_by.is_empty());
740 }
741
742 #[test]
743 fn trace_dependency_used_only_in_scripts() {
744 let files = vec![DiscoveredFile {
745 id: FileId(0),
746 path: PathBuf::from("/project/src/app.ts"),
747 size_bytes: 100,
748 }];
749 let entry_points = vec![EntryPoint {
750 path: PathBuf::from("/project/src/app.ts"),
751 source: EntryPointSource::PackageJsonMain,
752 }];
753 let resolved_modules = vec![ResolvedModule {
754 file_id: FileId(0),
755 path: PathBuf::from("/project/src/app.ts"),
756 ..Default::default()
757 }];
758
759 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
760 let root = Path::new("/project");
761 let mut script_used = FxHashSet::default();
762 script_used.insert("microbundle".to_string());
763
764 let trace = trace_dependency(&graph, root, "microbundle", &script_used);
765 assert!(
766 trace.is_used,
767 "is_used must be true when the package is referenced from package.json scripts"
768 );
769 assert!(trace.used_in_scripts);
770 assert_eq!(trace.import_count, 0);
771 assert!(trace.imported_by.is_empty());
772 }
773
774 #[test]
775 fn trace_clone_finds_matching_group() {
776 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
777 let report = DuplicationReport {
778 clone_groups: vec![CloneGroup {
779 instances: vec![
780 CloneInstance {
781 file: PathBuf::from("/project/src/a.ts"),
782 start_line: 10,
783 end_line: 20,
784 start_col: 0,
785 end_col: 0,
786 fragment: "fn foo() {}".to_string(),
787 },
788 CloneInstance {
789 file: PathBuf::from("/project/src/b.ts"),
790 start_line: 5,
791 end_line: 15,
792 start_col: 0,
793 end_col: 0,
794 fragment: "fn foo() {}".to_string(),
795 },
796 ],
797 token_count: 60,
798 line_count: 11,
799 }],
800 clone_families: vec![],
801 mirrored_directories: vec![],
802 stats: DuplicationStats {
803 total_files: 2,
804 files_with_clones: 2,
805 total_lines: 100,
806 duplicated_lines: 22,
807 total_tokens: 200,
808 duplicated_tokens: 120,
809 clone_groups: 1,
810 clone_instances: 2,
811 duplication_percentage: 22.0,
812 },
813 };
814 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 15);
815 assert!(trace.matched_instance.is_some());
816 assert_eq!(trace.clone_groups.len(), 1);
817 assert_eq!(trace.clone_groups[0].instances.len(), 2);
818 }
819
820 #[test]
821 fn trace_clone_no_match() {
822 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
823 let report = DuplicationReport {
824 clone_groups: vec![CloneGroup {
825 instances: vec![CloneInstance {
826 file: PathBuf::from("/project/src/a.ts"),
827 start_line: 10,
828 end_line: 20,
829 start_col: 0,
830 end_col: 0,
831 fragment: "fn foo() {}".to_string(),
832 }],
833 token_count: 60,
834 line_count: 11,
835 }],
836 clone_families: vec![],
837 mirrored_directories: vec![],
838 stats: DuplicationStats {
839 total_files: 1,
840 files_with_clones: 1,
841 total_lines: 50,
842 duplicated_lines: 11,
843 total_tokens: 100,
844 duplicated_tokens: 60,
845 clone_groups: 1,
846 clone_instances: 1,
847 duplication_percentage: 22.0,
848 },
849 };
850 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 25);
851 assert!(trace.matched_instance.is_none());
852 assert!(trace.clone_groups.is_empty());
853 }
854
855 #[test]
856 fn trace_clone_line_boundary() {
857 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
858 let report = DuplicationReport {
859 clone_groups: vec![CloneGroup {
860 instances: vec![
861 CloneInstance {
862 file: PathBuf::from("/project/src/a.ts"),
863 start_line: 10,
864 end_line: 20,
865 start_col: 0,
866 end_col: 0,
867 fragment: "code".to_string(),
868 },
869 CloneInstance {
870 file: PathBuf::from("/project/src/b.ts"),
871 start_line: 1,
872 end_line: 11,
873 start_col: 0,
874 end_col: 0,
875 fragment: "code".to_string(),
876 },
877 ],
878 token_count: 50,
879 line_count: 11,
880 }],
881 clone_families: vec![],
882 mirrored_directories: vec![],
883 stats: DuplicationStats {
884 total_files: 2,
885 files_with_clones: 2,
886 total_lines: 100,
887 duplicated_lines: 22,
888 total_tokens: 200,
889 duplicated_tokens: 100,
890 clone_groups: 1,
891 clone_instances: 2,
892 duplication_percentage: 22.0,
893 },
894 };
895 let root = Path::new("/project");
896 assert!(
897 trace_clone(&report, root, "src/a.ts", 10)
898 .matched_instance
899 .is_some()
900 );
901 assert!(
902 trace_clone(&report, root, "src/a.ts", 20)
903 .matched_instance
904 .is_some()
905 );
906 assert!(
907 trace_clone(&report, root, "src/a.ts", 21)
908 .matched_instance
909 .is_none()
910 );
911 }
912
913 #[test]
914 fn trace_clone_returns_relative_instance_paths() {
915 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
916 let report = DuplicationReport {
917 clone_groups: vec![CloneGroup {
918 instances: vec![
919 CloneInstance {
920 file: PathBuf::from("/project/src/a.ts"),
921 start_line: 1,
922 end_line: 10,
923 start_col: 0,
924 end_col: 0,
925 fragment: "code".to_string(),
926 },
927 CloneInstance {
928 file: PathBuf::from("/project/src/b.ts"),
929 start_line: 1,
930 end_line: 10,
931 start_col: 0,
932 end_col: 0,
933 fragment: "code".to_string(),
934 },
935 ],
936 token_count: 50,
937 line_count: 10,
938 }],
939 clone_families: vec![],
940 mirrored_directories: vec![],
941 stats: DuplicationStats {
942 total_files: 2,
943 files_with_clones: 2,
944 total_lines: 50,
945 duplicated_lines: 20,
946 total_tokens: 100,
947 duplicated_tokens: 100,
948 clone_groups: 1,
949 clone_instances: 2,
950 duplication_percentage: 40.0,
951 },
952 };
953 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 5);
954 let matched = trace.matched_instance.as_ref().expect("match expected");
955 assert_eq!(matched.file, PathBuf::from("src/a.ts"));
956 for group in &trace.clone_groups {
957 for inst in &group.instances {
958 let as_str = inst.file.to_string_lossy();
959 assert!(
960 !as_str.starts_with('/'),
961 "instance file should be relative, got {as_str}",
962 );
963 assert!(
964 !as_str.contains(":\\") && !as_str.contains(":/"),
965 "instance file should not have a drive letter, got {as_str}",
966 );
967 }
968 }
969
970 let json = serde_json::to_string(&trace).expect("serializes");
971 assert!(
972 !json.contains("\"/project/"),
973 "serialized trace should not leak absolute paths: {json}",
974 );
975 }
976}