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