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 super_class: None,
552 },
553 ExportInfo {
554 name: ExportName::Named("bar".to_string()),
555 local_name: Some("bar".to_string()),
556 is_type_only: false,
557 visibility: VisibilityTag::None,
558 span: oxc_span::Span::new(21, 40),
559 members: vec![],
560 super_class: None,
561 },
562 ],
563 ..Default::default()
564 },
565 ResolvedModule {
566 file_id: FileId(2),
567 path: PathBuf::from("/project/src/unused.ts"),
568 exports: vec![ExportInfo {
569 name: ExportName::Named("baz".to_string()),
570 local_name: Some("baz".to_string()),
571 is_type_only: false,
572 visibility: VisibilityTag::None,
573 span: oxc_span::Span::new(0, 15),
574 members: vec![],
575 super_class: None,
576 }],
577 ..Default::default()
578 },
579 ];
580
581 ModuleGraph::build(&resolved_modules, &entry_points, &files)
582 }
583
584 #[test]
585 fn trace_used_export() {
586 let graph = build_test_graph();
587 let root = Path::new("/project");
588
589 let trace = trace_export(&graph, root, "src/utils.ts", "foo").unwrap();
590 assert!(trace.is_used);
591 assert!(trace.file_reachable);
592 assert_eq!(trace.direct_references.len(), 1);
593 assert_eq!(
594 trace.direct_references[0].from_file,
595 PathBuf::from("src/entry.ts")
596 );
597 assert_eq!(trace.direct_references[0].kind, "named import");
598 }
599
600 #[test]
601 fn trace_unused_export() {
602 let graph = build_test_graph();
603 let root = Path::new("/project");
604
605 let trace = trace_export(&graph, root, "src/utils.ts", "bar").unwrap();
606 assert!(!trace.is_used);
607 assert!(trace.file_reachable);
608 assert!(trace.direct_references.is_empty());
609 }
610
611 #[test]
612 fn trace_unreachable_file_export() {
613 let graph = build_test_graph();
614 let root = Path::new("/project");
615
616 let trace = trace_export(&graph, root, "src/unused.ts", "baz").unwrap();
617 assert!(!trace.is_used);
618 assert!(!trace.file_reachable);
619 assert!(trace.reason.contains("unreachable"));
620 }
621
622 #[test]
623 fn trace_nonexistent_export() {
624 let graph = build_test_graph();
625 let root = Path::new("/project");
626
627 let trace = trace_export(&graph, root, "src/utils.ts", "nonexistent");
628 assert!(trace.is_none());
629 }
630
631 #[test]
632 fn trace_nonexistent_file() {
633 let graph = build_test_graph();
634 let root = Path::new("/project");
635
636 let trace = trace_export(&graph, root, "src/nope.ts", "foo");
637 assert!(trace.is_none());
638 }
639
640 #[test]
641 fn trace_file_edges() {
642 let graph = build_test_graph();
643 let root = Path::new("/project");
644
645 let trace = trace_file(&graph, root, "src/entry.ts").unwrap();
646 assert!(trace.is_entry_point);
647 assert!(trace.is_reachable);
648 assert_eq!(trace.imports_from.len(), 1);
649 assert_eq!(trace.imports_from[0], PathBuf::from("src/utils.ts"));
650 assert!(trace.imported_by.is_empty());
651 }
652
653 #[test]
654 fn trace_file_imported_by() {
655 let graph = build_test_graph();
656 let root = Path::new("/project");
657
658 let trace = trace_file(&graph, root, "src/utils.ts").unwrap();
659 assert!(!trace.is_entry_point);
660 assert!(trace.is_reachable);
661 assert_eq!(trace.exports.len(), 2);
662 assert_eq!(trace.imported_by.len(), 1);
663 assert_eq!(trace.imported_by[0], PathBuf::from("src/entry.ts"));
664 }
665
666 #[test]
667 fn trace_unreachable_file() {
668 let graph = build_test_graph();
669 let root = Path::new("/project");
670
671 let trace = trace_file(&graph, root, "src/unused.ts").unwrap();
672 assert!(!trace.is_reachable);
673 assert!(!trace.is_entry_point);
674 assert!(trace.imported_by.is_empty());
675 }
676
677 #[test]
678 fn trace_dependency_used() {
679 let files = vec![DiscoveredFile {
681 id: FileId(0),
682 path: PathBuf::from("/project/src/app.ts"),
683 size_bytes: 100,
684 }];
685 let entry_points = vec![EntryPoint {
686 path: PathBuf::from("/project/src/app.ts"),
687 source: EntryPointSource::PackageJsonMain,
688 }];
689 let resolved_modules = vec![ResolvedModule {
690 file_id: FileId(0),
691 path: PathBuf::from("/project/src/app.ts"),
692 resolved_imports: vec![ResolvedImport {
693 info: ImportInfo {
694 source: "lodash".to_string(),
695 imported_name: ImportedName::Named("get".to_string()),
696 local_name: "get".to_string(),
697 is_type_only: false,
698 from_style: false,
699 span: oxc_span::Span::new(0, 10),
700 source_span: oxc_span::Span::default(),
701 },
702 target: ResolveResult::NpmPackage("lodash".to_string()),
703 }],
704 ..Default::default()
705 }];
706
707 let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
708 let root = Path::new("/project");
709
710 let trace = trace_dependency(&graph, root, "lodash", &FxHashSet::default());
711 assert!(trace.is_used);
712 assert!(!trace.used_in_scripts);
713 assert_eq!(trace.import_count, 1);
714 assert_eq!(trace.imported_by[0], PathBuf::from("src/app.ts"));
715 }
716
717 #[test]
718 fn trace_dependency_unused() {
719 let files = vec![DiscoveredFile {
720 id: FileId(0),
721 path: PathBuf::from("/project/src/app.ts"),
722 size_bytes: 100,
723 }];
724 let entry_points = vec![EntryPoint {
725 path: PathBuf::from("/project/src/app.ts"),
726 source: EntryPointSource::PackageJsonMain,
727 }];
728 let resolved_modules = vec![ResolvedModule {
729 file_id: FileId(0),
730 path: PathBuf::from("/project/src/app.ts"),
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, "nonexistent-pkg", &FxHashSet::default());
738 assert!(!trace.is_used);
739 assert!(!trace.used_in_scripts);
740 assert_eq!(trace.import_count, 0);
741 assert!(trace.imported_by.is_empty());
742 }
743
744 #[test]
745 fn trace_dependency_used_only_in_scripts() {
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 let mut script_used = FxHashSet::default();
764 script_used.insert("microbundle".to_string());
765
766 let trace = trace_dependency(&graph, root, "microbundle", &script_used);
767 assert!(
768 trace.is_used,
769 "is_used must be true when the package is referenced from package.json scripts"
770 );
771 assert!(trace.used_in_scripts);
772 assert_eq!(trace.import_count, 0);
773 assert!(trace.imported_by.is_empty());
774 }
775
776 #[test]
777 fn trace_clone_finds_matching_group() {
778 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
779 let report = DuplicationReport {
780 clone_groups: vec![CloneGroup {
781 instances: vec![
782 CloneInstance {
783 file: PathBuf::from("/project/src/a.ts"),
784 start_line: 10,
785 end_line: 20,
786 start_col: 0,
787 end_col: 0,
788 fragment: "fn foo() {}".to_string(),
789 },
790 CloneInstance {
791 file: PathBuf::from("/project/src/b.ts"),
792 start_line: 5,
793 end_line: 15,
794 start_col: 0,
795 end_col: 0,
796 fragment: "fn foo() {}".to_string(),
797 },
798 ],
799 token_count: 60,
800 line_count: 11,
801 }],
802 clone_families: vec![],
803 mirrored_directories: vec![],
804 stats: DuplicationStats {
805 total_files: 2,
806 files_with_clones: 2,
807 total_lines: 100,
808 duplicated_lines: 22,
809 total_tokens: 200,
810 duplicated_tokens: 120,
811 clone_groups: 1,
812 clone_instances: 2,
813 duplication_percentage: 22.0,
814 },
815 };
816 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 15);
817 assert!(trace.matched_instance.is_some());
818 assert_eq!(trace.clone_groups.len(), 1);
819 assert_eq!(trace.clone_groups[0].instances.len(), 2);
820 }
821
822 #[test]
823 fn trace_clone_no_match() {
824 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
825 let report = DuplicationReport {
826 clone_groups: vec![CloneGroup {
827 instances: vec![CloneInstance {
828 file: PathBuf::from("/project/src/a.ts"),
829 start_line: 10,
830 end_line: 20,
831 start_col: 0,
832 end_col: 0,
833 fragment: "fn foo() {}".to_string(),
834 }],
835 token_count: 60,
836 line_count: 11,
837 }],
838 clone_families: vec![],
839 mirrored_directories: vec![],
840 stats: DuplicationStats {
841 total_files: 1,
842 files_with_clones: 1,
843 total_lines: 50,
844 duplicated_lines: 11,
845 total_tokens: 100,
846 duplicated_tokens: 60,
847 clone_groups: 1,
848 clone_instances: 1,
849 duplication_percentage: 22.0,
850 },
851 };
852 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 25);
853 assert!(trace.matched_instance.is_none());
854 assert!(trace.clone_groups.is_empty());
855 }
856
857 #[test]
858 fn trace_clone_line_boundary() {
859 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
860 let report = DuplicationReport {
861 clone_groups: vec![CloneGroup {
862 instances: vec![
863 CloneInstance {
864 file: PathBuf::from("/project/src/a.ts"),
865 start_line: 10,
866 end_line: 20,
867 start_col: 0,
868 end_col: 0,
869 fragment: "code".to_string(),
870 },
871 CloneInstance {
872 file: PathBuf::from("/project/src/b.ts"),
873 start_line: 1,
874 end_line: 11,
875 start_col: 0,
876 end_col: 0,
877 fragment: "code".to_string(),
878 },
879 ],
880 token_count: 50,
881 line_count: 11,
882 }],
883 clone_families: vec![],
884 mirrored_directories: vec![],
885 stats: DuplicationStats {
886 total_files: 2,
887 files_with_clones: 2,
888 total_lines: 100,
889 duplicated_lines: 22,
890 total_tokens: 200,
891 duplicated_tokens: 100,
892 clone_groups: 1,
893 clone_instances: 2,
894 duplication_percentage: 22.0,
895 },
896 };
897 let root = Path::new("/project");
898 assert!(
899 trace_clone(&report, root, "src/a.ts", 10)
900 .matched_instance
901 .is_some()
902 );
903 assert!(
904 trace_clone(&report, root, "src/a.ts", 20)
905 .matched_instance
906 .is_some()
907 );
908 assert!(
909 trace_clone(&report, root, "src/a.ts", 21)
910 .matched_instance
911 .is_none()
912 );
913 }
914
915 #[test]
916 fn trace_clone_returns_relative_instance_paths() {
917 use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
918 let report = DuplicationReport {
919 clone_groups: vec![CloneGroup {
920 instances: vec![
921 CloneInstance {
922 file: PathBuf::from("/project/src/a.ts"),
923 start_line: 1,
924 end_line: 10,
925 start_col: 0,
926 end_col: 0,
927 fragment: "code".to_string(),
928 },
929 CloneInstance {
930 file: PathBuf::from("/project/src/b.ts"),
931 start_line: 1,
932 end_line: 10,
933 start_col: 0,
934 end_col: 0,
935 fragment: "code".to_string(),
936 },
937 ],
938 token_count: 50,
939 line_count: 10,
940 }],
941 clone_families: vec![],
942 mirrored_directories: vec![],
943 stats: DuplicationStats {
944 total_files: 2,
945 files_with_clones: 2,
946 total_lines: 50,
947 duplicated_lines: 20,
948 total_tokens: 100,
949 duplicated_tokens: 100,
950 clone_groups: 1,
951 clone_instances: 2,
952 duplication_percentage: 40.0,
953 },
954 };
955 let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 5);
956 let matched = trace.matched_instance.as_ref().expect("match expected");
957 assert_eq!(matched.file, PathBuf::from("src/a.ts"));
958 for group in &trace.clone_groups {
959 for inst in &group.instances {
960 let as_str = inst.file.to_string_lossy();
961 assert!(
962 !as_str.starts_with('/'),
963 "instance file should be relative, got {as_str}",
964 );
965 assert!(
966 !as_str.contains(":\\") && !as_str.contains(":/"),
967 "instance file should not have a drive letter, got {as_str}",
968 );
969 }
970 }
971
972 let json = serde_json::to_string(&trace).expect("serializes");
973 assert!(
974 !json.contains("\"/project/"),
975 "serialized trace should not leak absolute paths: {json}",
976 );
977 }
978}