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