Skip to main content

fallow_core/
trace.rs

1use std::path::{Path, PathBuf};
2
3use serde::Serialize;
4
5use crate::duplicates::{CloneInstance, DuplicationReport};
6use crate::graph::{ModuleGraph, ReferenceKind};
7
8/// Match a user-provided file path against a module's actual path.
9///
10/// Handles monorepo scenarios where module paths may be canonicalized
11/// (symlinks resolved) while user-provided paths are not.
12fn path_matches(module_path: &Path, root: &Path, user_path: &str) -> bool {
13    let rel = module_path.strip_prefix(root).unwrap_or(module_path);
14    let rel_str = rel.to_string_lossy();
15    if rel_str == user_path || module_path.to_string_lossy() == user_path {
16        return true;
17    }
18    if root.canonicalize().is_ok_and(|canonical_root| {
19        module_path
20            .strip_prefix(&canonical_root)
21            .is_ok_and(|rel| rel.to_string_lossy() == user_path)
22    }) {
23        return true;
24    }
25    let module_str = module_path.to_string_lossy();
26    module_str.ends_with(&format!("/{user_path}"))
27}
28
29/// Result of tracing an export: why is it considered used or unused?
30#[derive(Debug, Serialize)]
31pub struct ExportTrace {
32    /// The file containing the export.
33    pub file: PathBuf,
34    /// The export name being traced.
35    pub export_name: String,
36    /// Whether the file is reachable from an entry point.
37    pub file_reachable: bool,
38    /// Whether the file is an entry point.
39    pub is_entry_point: bool,
40    /// Whether the export is considered used.
41    pub is_used: bool,
42    /// Files that reference this export directly.
43    pub direct_references: Vec<ExportReference>,
44    /// Re-export chains that pass through this export.
45    pub re_export_chains: Vec<ReExportChain>,
46    /// Reason summary.
47    pub reason: String,
48}
49
50/// A direct reference to an export.
51#[derive(Debug, Serialize)]
52pub struct ExportReference {
53    pub from_file: PathBuf,
54    pub kind: String,
55}
56
57/// A re-export chain showing how an export is propagated.
58#[derive(Debug, Serialize)]
59pub struct ReExportChain {
60    /// The barrel file that re-exports this symbol.
61    pub barrel_file: PathBuf,
62    /// The name it's re-exported as.
63    pub exported_as: String,
64    /// Number of references on the barrel's re-exported symbol.
65    pub reference_count: usize,
66}
67
68/// Result of tracing all edges for a file.
69#[derive(Debug, Serialize)]
70pub struct FileTrace {
71    /// The traced file.
72    pub file: PathBuf,
73    /// Whether this file is reachable from entry points.
74    pub is_reachable: bool,
75    /// Whether this file is an entry point.
76    pub is_entry_point: bool,
77    /// Exports declared by this file.
78    pub exports: Vec<TracedExport>,
79    /// Files that this file imports from.
80    pub imports_from: Vec<PathBuf>,
81    /// Files that import from this file.
82    pub imported_by: Vec<PathBuf>,
83    /// Re-exports declared by this file.
84    pub re_exports: Vec<TracedReExport>,
85}
86
87/// An export with its usage info.
88#[derive(Debug, Serialize)]
89pub struct TracedExport {
90    pub name: String,
91    pub is_type_only: bool,
92    pub reference_count: usize,
93    pub referenced_by: Vec<ExportReference>,
94}
95
96/// A re-export with source info.
97#[derive(Debug, Serialize)]
98pub struct TracedReExport {
99    pub source_file: PathBuf,
100    pub imported_name: String,
101    pub exported_name: String,
102}
103
104/// Result of tracing a dependency: where is it used?
105#[derive(Debug, Serialize)]
106pub struct DependencyTrace {
107    /// The dependency name being traced.
108    pub package_name: String,
109    /// Files that import this dependency.
110    pub imported_by: Vec<PathBuf>,
111    /// Files that import this dependency with type-only imports.
112    pub type_only_imported_by: Vec<PathBuf>,
113    /// Whether the dependency is used at all.
114    pub is_used: bool,
115    /// Total import count.
116    pub import_count: usize,
117}
118
119/// Pipeline performance timings.
120#[derive(Debug, Clone, Serialize)]
121pub struct PipelineTimings {
122    pub discover_files_ms: f64,
123    pub file_count: usize,
124    pub workspaces_ms: f64,
125    pub workspace_count: usize,
126    pub plugins_ms: f64,
127    pub script_analysis_ms: f64,
128    pub parse_extract_ms: f64,
129    pub module_count: usize,
130    /// Number of files whose parse results were loaded from cache (skipped parsing).
131    pub cache_hits: usize,
132    /// Number of files that required a full parse (new or changed content).
133    pub cache_misses: usize,
134    pub cache_update_ms: f64,
135    pub entry_points_ms: f64,
136    pub entry_point_count: usize,
137    pub resolve_imports_ms: f64,
138    pub build_graph_ms: f64,
139    pub analyze_ms: f64,
140    pub total_ms: f64,
141}
142
143/// Trace why an export is considered used or unused.
144#[must_use]
145pub fn trace_export(
146    graph: &ModuleGraph,
147    root: &Path,
148    file_path: &str,
149    export_name: &str,
150) -> Option<ExportTrace> {
151    // Find the file in the graph
152    let module = graph
153        .modules
154        .iter()
155        .find(|m| path_matches(&m.path, root, file_path))?;
156
157    // Find the export
158    let export = module.exports.iter().find(|e| {
159        let name_str = e.name.to_string();
160        name_str == export_name || (export_name == "default" && name_str == "default")
161    })?;
162
163    let direct_references: Vec<ExportReference> = export
164        .references
165        .iter()
166        .map(|r| {
167            let from_path = graph.modules.get(r.from_file.0 as usize).map_or_else(
168                || PathBuf::from(format!("<unknown:{}>", r.from_file.0)),
169                |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
170            );
171            ExportReference {
172                from_file: from_path,
173                kind: format_reference_kind(&r.kind),
174            }
175        })
176        .collect();
177
178    // Find re-export chains involving this export
179    let re_export_chains: Vec<ReExportChain> = graph
180        .modules
181        .iter()
182        .flat_map(|m| {
183            m.re_exports
184                .iter()
185                .filter(|re| {
186                    re.source_file == module.file_id
187                        && (re.imported_name == export_name || re.imported_name == "*")
188                })
189                .map(|re| {
190                    let barrel_export = m.exports.iter().find(|e| {
191                        if re.exported_name == "*" {
192                            e.name.to_string() == export_name
193                        } else {
194                            e.name.to_string() == re.exported_name
195                        }
196                    });
197                    ReExportChain {
198                        barrel_file: m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
199                        exported_as: re.exported_name.clone(),
200                        reference_count: barrel_export.map_or(0, |e| e.references.len()),
201                    }
202                })
203        })
204        .collect();
205
206    let is_used = !export.references.is_empty();
207    let reason = if !module.is_reachable {
208        "File is unreachable from any entry point".to_string()
209    } else if is_used {
210        format!(
211            "Used by {} file(s){}",
212            export.references.len(),
213            if re_export_chains.is_empty() {
214                String::new()
215            } else {
216                format!(", re-exported through {} barrel(s)", re_export_chains.len())
217            }
218        )
219    } else if module.is_entry_point {
220        "No internal references, but file is an entry point (export is externally accessible)"
221            .to_string()
222    } else if !re_export_chains.is_empty() {
223        format!(
224            "Re-exported through {} barrel(s) but no consumer imports it through the barrel",
225            re_export_chains.len()
226        )
227    } else {
228        "No references found — export is unused".to_string()
229    };
230
231    Some(ExportTrace {
232        file: module
233            .path
234            .strip_prefix(root)
235            .unwrap_or(&module.path)
236            .to_path_buf(),
237        export_name: export_name.to_string(),
238        file_reachable: module.is_reachable,
239        is_entry_point: module.is_entry_point,
240        is_used,
241        direct_references,
242        re_export_chains,
243        reason,
244    })
245}
246
247/// Trace all edges for a file.
248#[must_use]
249pub fn trace_file(graph: &ModuleGraph, root: &Path, file_path: &str) -> Option<FileTrace> {
250    let module = graph
251        .modules
252        .iter()
253        .find(|m| path_matches(&m.path, root, file_path))?;
254
255    let exports: Vec<TracedExport> = module
256        .exports
257        .iter()
258        .map(|e| TracedExport {
259            name: e.name.to_string(),
260            is_type_only: e.is_type_only,
261            reference_count: e.references.len(),
262            referenced_by: e
263                .references
264                .iter()
265                .map(|r| {
266                    let from_path = graph.modules.get(r.from_file.0 as usize).map_or_else(
267                        || PathBuf::from(format!("<unknown:{}>", r.from_file.0)),
268                        |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
269                    );
270                    ExportReference {
271                        from_file: from_path,
272                        kind: format_reference_kind(&r.kind),
273                    }
274                })
275                .collect(),
276        })
277        .collect();
278
279    // Edges FROM this file (what it imports)
280    let imports_from: Vec<PathBuf> = graph
281        .edges_for(module.file_id)
282        .iter()
283        .filter_map(|target_id| {
284            graph
285                .modules
286                .get(target_id.0 as usize)
287                .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
288        })
289        .collect();
290
291    // Reverse deps: who imports this file
292    let imported_by: Vec<PathBuf> = graph
293        .reverse_deps
294        .get(module.file_id.0 as usize)
295        .map(|deps| {
296            deps.iter()
297                .filter_map(|fid| {
298                    graph
299                        .modules
300                        .get(fid.0 as usize)
301                        .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
302                })
303                .collect()
304        })
305        .unwrap_or_default();
306
307    let re_exports: Vec<TracedReExport> = module
308        .re_exports
309        .iter()
310        .map(|re| {
311            let source_path = graph.modules.get(re.source_file.0 as usize).map_or_else(
312                || PathBuf::from(format!("<unknown:{}>", re.source_file.0)),
313                |m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf(),
314            );
315            TracedReExport {
316                source_file: source_path,
317                imported_name: re.imported_name.clone(),
318                exported_name: re.exported_name.clone(),
319            }
320        })
321        .collect();
322
323    Some(FileTrace {
324        file: module
325            .path
326            .strip_prefix(root)
327            .unwrap_or(&module.path)
328            .to_path_buf(),
329        is_reachable: module.is_reachable,
330        is_entry_point: module.is_entry_point,
331        exports,
332        imports_from,
333        imported_by,
334        re_exports,
335    })
336}
337
338/// Trace where a dependency is used.
339#[must_use]
340pub fn trace_dependency(graph: &ModuleGraph, root: &Path, package_name: &str) -> DependencyTrace {
341    let imported_by: Vec<PathBuf> = graph
342        .package_usage
343        .get(package_name)
344        .map(|ids| {
345            ids.iter()
346                .filter_map(|fid| {
347                    graph
348                        .modules
349                        .get(fid.0 as usize)
350                        .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
351                })
352                .collect()
353        })
354        .unwrap_or_default();
355
356    let type_only_imported_by: Vec<PathBuf> = graph
357        .type_only_package_usage
358        .get(package_name)
359        .map(|ids| {
360            ids.iter()
361                .filter_map(|fid| {
362                    graph
363                        .modules
364                        .get(fid.0 as usize)
365                        .map(|m| m.path.strip_prefix(root).unwrap_or(&m.path).to_path_buf())
366                })
367                .collect()
368        })
369        .unwrap_or_default();
370
371    let import_count = imported_by.len();
372    DependencyTrace {
373        package_name: package_name.to_string(),
374        imported_by,
375        type_only_imported_by,
376        is_used: import_count > 0,
377        import_count,
378    }
379}
380
381fn format_reference_kind(kind: &ReferenceKind) -> String {
382    match kind {
383        ReferenceKind::NamedImport => "named import".to_string(),
384        ReferenceKind::DefaultImport => "default import".to_string(),
385        ReferenceKind::NamespaceImport => "namespace import".to_string(),
386        ReferenceKind::ReExport => "re-export".to_string(),
387        ReferenceKind::DynamicImport => "dynamic import".to_string(),
388        ReferenceKind::SideEffectImport => "side-effect import".to_string(),
389    }
390}
391
392/// Result of tracing a clone: all groups containing the code at a given location.
393#[derive(Debug, Serialize)]
394pub struct CloneTrace {
395    pub file: PathBuf,
396    pub line: usize,
397    pub matched_instance: Option<CloneInstance>,
398    pub clone_groups: Vec<TracedCloneGroup>,
399}
400
401#[derive(Debug, Serialize)]
402pub struct TracedCloneGroup {
403    pub token_count: usize,
404    pub line_count: usize,
405    pub instances: Vec<CloneInstance>,
406}
407
408#[must_use]
409pub fn trace_clone(
410    report: &DuplicationReport,
411    root: &Path,
412    file_path: &str,
413    line: usize,
414) -> CloneTrace {
415    let resolved = root.join(file_path);
416    let mut matched_instance = None;
417    let mut clone_groups = Vec::new();
418
419    for group in &report.clone_groups {
420        let matching = group.instances.iter().find(|inst| {
421            let inst_matches = inst.file == resolved
422                || inst.file.strip_prefix(root).unwrap_or(&inst.file) == Path::new(file_path);
423            inst_matches && inst.start_line <= line && line <= inst.end_line
424        });
425
426        if let Some(matched) = matching {
427            if matched_instance.is_none() {
428                matched_instance = Some(matched.clone());
429            }
430            clone_groups.push(TracedCloneGroup {
431                token_count: group.token_count,
432                line_count: group.line_count,
433                instances: group.instances.clone(),
434            });
435        }
436    }
437
438    CloneTrace {
439        file: PathBuf::from(file_path),
440        line,
441        matched_instance,
442        clone_groups,
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449    use rustc_hash::FxHashSet;
450
451    use crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
452    use crate::extract::{ExportInfo, ExportName, ImportInfo, ImportedName};
453    use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
454
455    fn build_test_graph() -> ModuleGraph {
456        let files = vec![
457            DiscoveredFile {
458                id: FileId(0),
459                path: PathBuf::from("/project/src/entry.ts"),
460                size_bytes: 100,
461            },
462            DiscoveredFile {
463                id: FileId(1),
464                path: PathBuf::from("/project/src/utils.ts"),
465                size_bytes: 50,
466            },
467            DiscoveredFile {
468                id: FileId(2),
469                path: PathBuf::from("/project/src/unused.ts"),
470                size_bytes: 30,
471            },
472        ];
473
474        let entry_points = vec![EntryPoint {
475            path: PathBuf::from("/project/src/entry.ts"),
476            source: EntryPointSource::PackageJsonMain,
477        }];
478
479        let resolved_modules = vec![
480            ResolvedModule {
481                file_id: FileId(0),
482                path: PathBuf::from("/project/src/entry.ts"),
483                exports: vec![],
484                re_exports: vec![],
485                resolved_imports: vec![ResolvedImport {
486                    info: ImportInfo {
487                        source: "./utils".to_string(),
488                        imported_name: ImportedName::Named("foo".to_string()),
489                        local_name: "foo".to_string(),
490                        is_type_only: false,
491                        span: oxc_span::Span::new(0, 10),
492                        source_span: oxc_span::Span::default(),
493                    },
494                    target: ResolveResult::InternalModule(FileId(1)),
495                }],
496                resolved_dynamic_imports: vec![],
497                resolved_dynamic_patterns: vec![],
498                member_accesses: vec![],
499                whole_object_uses: vec![],
500                has_cjs_exports: false,
501                unused_import_bindings: FxHashSet::default(),
502            },
503            ResolvedModule {
504                file_id: FileId(1),
505                path: PathBuf::from("/project/src/utils.ts"),
506                exports: vec![
507                    ExportInfo {
508                        name: ExportName::Named("foo".to_string()),
509                        local_name: Some("foo".to_string()),
510                        is_type_only: false,
511                        is_public: false,
512                        span: oxc_span::Span::new(0, 20),
513                        members: vec![],
514                    },
515                    ExportInfo {
516                        name: ExportName::Named("bar".to_string()),
517                        local_name: Some("bar".to_string()),
518                        is_type_only: false,
519                        is_public: false,
520                        span: oxc_span::Span::new(21, 40),
521                        members: vec![],
522                    },
523                ],
524                re_exports: vec![],
525                resolved_imports: vec![],
526                resolved_dynamic_imports: vec![],
527                resolved_dynamic_patterns: vec![],
528                member_accesses: vec![],
529                whole_object_uses: vec![],
530                has_cjs_exports: false,
531                unused_import_bindings: FxHashSet::default(),
532            },
533            ResolvedModule {
534                file_id: FileId(2),
535                path: PathBuf::from("/project/src/unused.ts"),
536                exports: vec![ExportInfo {
537                    name: ExportName::Named("baz".to_string()),
538                    local_name: Some("baz".to_string()),
539                    is_type_only: false,
540                    is_public: false,
541                    span: oxc_span::Span::new(0, 15),
542                    members: vec![],
543                }],
544                re_exports: vec![],
545                resolved_imports: vec![],
546                resolved_dynamic_imports: vec![],
547                resolved_dynamic_patterns: vec![],
548                member_accesses: vec![],
549                whole_object_uses: vec![],
550                has_cjs_exports: false,
551                unused_import_bindings: FxHashSet::default(),
552            },
553        ];
554
555        ModuleGraph::build(&resolved_modules, &entry_points, &files)
556    }
557
558    #[test]
559    fn trace_used_export() {
560        let graph = build_test_graph();
561        let root = Path::new("/project");
562
563        let trace = trace_export(&graph, root, "src/utils.ts", "foo").unwrap();
564        assert!(trace.is_used);
565        assert!(trace.file_reachable);
566        assert_eq!(trace.direct_references.len(), 1);
567        assert_eq!(
568            trace.direct_references[0].from_file,
569            PathBuf::from("src/entry.ts")
570        );
571        assert_eq!(trace.direct_references[0].kind, "named import");
572    }
573
574    #[test]
575    fn trace_unused_export() {
576        let graph = build_test_graph();
577        let root = Path::new("/project");
578
579        let trace = trace_export(&graph, root, "src/utils.ts", "bar").unwrap();
580        assert!(!trace.is_used);
581        assert!(trace.file_reachable);
582        assert!(trace.direct_references.is_empty());
583    }
584
585    #[test]
586    fn trace_unreachable_file_export() {
587        let graph = build_test_graph();
588        let root = Path::new("/project");
589
590        let trace = trace_export(&graph, root, "src/unused.ts", "baz").unwrap();
591        assert!(!trace.is_used);
592        assert!(!trace.file_reachable);
593        assert!(trace.reason.contains("unreachable"));
594    }
595
596    #[test]
597    fn trace_nonexistent_export() {
598        let graph = build_test_graph();
599        let root = Path::new("/project");
600
601        let trace = trace_export(&graph, root, "src/utils.ts", "nonexistent");
602        assert!(trace.is_none());
603    }
604
605    #[test]
606    fn trace_nonexistent_file() {
607        let graph = build_test_graph();
608        let root = Path::new("/project");
609
610        let trace = trace_export(&graph, root, "src/nope.ts", "foo");
611        assert!(trace.is_none());
612    }
613
614    #[test]
615    fn trace_file_edges() {
616        let graph = build_test_graph();
617        let root = Path::new("/project");
618
619        let trace = trace_file(&graph, root, "src/entry.ts").unwrap();
620        assert!(trace.is_entry_point);
621        assert!(trace.is_reachable);
622        assert_eq!(trace.imports_from.len(), 1);
623        assert_eq!(trace.imports_from[0], PathBuf::from("src/utils.ts"));
624        assert!(trace.imported_by.is_empty());
625    }
626
627    #[test]
628    fn trace_file_imported_by() {
629        let graph = build_test_graph();
630        let root = Path::new("/project");
631
632        let trace = trace_file(&graph, root, "src/utils.ts").unwrap();
633        assert!(!trace.is_entry_point);
634        assert!(trace.is_reachable);
635        assert_eq!(trace.exports.len(), 2);
636        assert_eq!(trace.imported_by.len(), 1);
637        assert_eq!(trace.imported_by[0], PathBuf::from("src/entry.ts"));
638    }
639
640    #[test]
641    fn trace_unreachable_file() {
642        let graph = build_test_graph();
643        let root = Path::new("/project");
644
645        let trace = trace_file(&graph, root, "src/unused.ts").unwrap();
646        assert!(!trace.is_reachable);
647        assert!(!trace.is_entry_point);
648        assert!(trace.imported_by.is_empty());
649    }
650
651    #[test]
652    fn trace_dependency_used() {
653        // Build a graph with npm package usage
654        let files = vec![DiscoveredFile {
655            id: FileId(0),
656            path: PathBuf::from("/project/src/app.ts"),
657            size_bytes: 100,
658        }];
659        let entry_points = vec![EntryPoint {
660            path: PathBuf::from("/project/src/app.ts"),
661            source: EntryPointSource::PackageJsonMain,
662        }];
663        let resolved_modules = vec![ResolvedModule {
664            file_id: FileId(0),
665            path: PathBuf::from("/project/src/app.ts"),
666            exports: vec![],
667            re_exports: vec![],
668            resolved_imports: vec![ResolvedImport {
669                info: ImportInfo {
670                    source: "lodash".to_string(),
671                    imported_name: ImportedName::Named("get".to_string()),
672                    local_name: "get".to_string(),
673                    is_type_only: false,
674                    span: oxc_span::Span::new(0, 10),
675                    source_span: oxc_span::Span::default(),
676                },
677                target: ResolveResult::NpmPackage("lodash".to_string()),
678            }],
679            resolved_dynamic_imports: vec![],
680            resolved_dynamic_patterns: vec![],
681            member_accesses: vec![],
682            whole_object_uses: vec![],
683            has_cjs_exports: false,
684            unused_import_bindings: FxHashSet::default(),
685        }];
686
687        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
688        let root = Path::new("/project");
689
690        let trace = trace_dependency(&graph, root, "lodash");
691        assert!(trace.is_used);
692        assert_eq!(trace.import_count, 1);
693        assert_eq!(trace.imported_by[0], PathBuf::from("src/app.ts"));
694    }
695
696    #[test]
697    fn trace_dependency_unused() {
698        let files = vec![DiscoveredFile {
699            id: FileId(0),
700            path: PathBuf::from("/project/src/app.ts"),
701            size_bytes: 100,
702        }];
703        let entry_points = vec![EntryPoint {
704            path: PathBuf::from("/project/src/app.ts"),
705            source: EntryPointSource::PackageJsonMain,
706        }];
707        let resolved_modules = vec![ResolvedModule {
708            file_id: FileId(0),
709            path: PathBuf::from("/project/src/app.ts"),
710            exports: vec![],
711            re_exports: vec![],
712            resolved_imports: vec![],
713            resolved_dynamic_imports: vec![],
714            resolved_dynamic_patterns: vec![],
715            member_accesses: vec![],
716            whole_object_uses: vec![],
717            has_cjs_exports: false,
718            unused_import_bindings: FxHashSet::default(),
719        }];
720
721        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
722        let root = Path::new("/project");
723
724        let trace = trace_dependency(&graph, root, "nonexistent-pkg");
725        assert!(!trace.is_used);
726        assert_eq!(trace.import_count, 0);
727        assert!(trace.imported_by.is_empty());
728    }
729
730    #[test]
731    fn trace_clone_finds_matching_group() {
732        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
733        let report = DuplicationReport {
734            clone_groups: vec![CloneGroup {
735                instances: vec![
736                    CloneInstance {
737                        file: PathBuf::from("/project/src/a.ts"),
738                        start_line: 10,
739                        end_line: 20,
740                        start_col: 0,
741                        end_col: 0,
742                        fragment: "fn foo() {}".to_string(),
743                    },
744                    CloneInstance {
745                        file: PathBuf::from("/project/src/b.ts"),
746                        start_line: 5,
747                        end_line: 15,
748                        start_col: 0,
749                        end_col: 0,
750                        fragment: "fn foo() {}".to_string(),
751                    },
752                ],
753                token_count: 60,
754                line_count: 11,
755            }],
756            clone_families: vec![],
757            stats: DuplicationStats {
758                total_files: 2,
759                files_with_clones: 2,
760                total_lines: 100,
761                duplicated_lines: 22,
762                total_tokens: 200,
763                duplicated_tokens: 120,
764                clone_groups: 1,
765                clone_instances: 2,
766                duplication_percentage: 22.0,
767            },
768        };
769        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 15);
770        assert!(trace.matched_instance.is_some());
771        assert_eq!(trace.clone_groups.len(), 1);
772        assert_eq!(trace.clone_groups[0].instances.len(), 2);
773    }
774
775    #[test]
776    fn trace_clone_no_match() {
777        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
778        let report = DuplicationReport {
779            clone_groups: vec![CloneGroup {
780                instances: vec![CloneInstance {
781                    file: PathBuf::from("/project/src/a.ts"),
782                    start_line: 10,
783                    end_line: 20,
784                    start_col: 0,
785                    end_col: 0,
786                    fragment: "fn foo() {}".to_string(),
787                }],
788                token_count: 60,
789                line_count: 11,
790            }],
791            clone_families: vec![],
792            stats: DuplicationStats {
793                total_files: 1,
794                files_with_clones: 1,
795                total_lines: 50,
796                duplicated_lines: 11,
797                total_tokens: 100,
798                duplicated_tokens: 60,
799                clone_groups: 1,
800                clone_instances: 1,
801                duplication_percentage: 22.0,
802            },
803        };
804        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 25);
805        assert!(trace.matched_instance.is_none());
806        assert!(trace.clone_groups.is_empty());
807    }
808
809    #[test]
810    fn trace_clone_line_boundary() {
811        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
812        let report = DuplicationReport {
813            clone_groups: vec![CloneGroup {
814                instances: vec![
815                    CloneInstance {
816                        file: PathBuf::from("/project/src/a.ts"),
817                        start_line: 10,
818                        end_line: 20,
819                        start_col: 0,
820                        end_col: 0,
821                        fragment: "code".to_string(),
822                    },
823                    CloneInstance {
824                        file: PathBuf::from("/project/src/b.ts"),
825                        start_line: 1,
826                        end_line: 11,
827                        start_col: 0,
828                        end_col: 0,
829                        fragment: "code".to_string(),
830                    },
831                ],
832                token_count: 50,
833                line_count: 11,
834            }],
835            clone_families: vec![],
836            stats: DuplicationStats {
837                total_files: 2,
838                files_with_clones: 2,
839                total_lines: 100,
840                duplicated_lines: 22,
841                total_tokens: 200,
842                duplicated_tokens: 100,
843                clone_groups: 1,
844                clone_instances: 2,
845                duplication_percentage: 22.0,
846            },
847        };
848        let root = Path::new("/project");
849        assert!(
850            trace_clone(&report, root, "src/a.ts", 10)
851                .matched_instance
852                .is_some()
853        );
854        assert!(
855            trace_clone(&report, root, "src/a.ts", 20)
856                .matched_instance
857                .is_some()
858        );
859        assert!(
860            trace_clone(&report, root, "src/a.ts", 21)
861                .matched_instance
862                .is_none()
863        );
864    }
865}