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