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