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 crate::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
446    use crate::extract::{ExportInfo, ExportName, ImportInfo, ImportedName};
447    use crate::resolve::{ResolveResult, ResolvedImport, ResolvedModule};
448
449    fn build_test_graph() -> ModuleGraph {
450        let files = vec![
451            DiscoveredFile {
452                id: FileId(0),
453                path: PathBuf::from("/project/src/entry.ts"),
454                size_bytes: 100,
455            },
456            DiscoveredFile {
457                id: FileId(1),
458                path: PathBuf::from("/project/src/utils.ts"),
459                size_bytes: 50,
460            },
461            DiscoveredFile {
462                id: FileId(2),
463                path: PathBuf::from("/project/src/unused.ts"),
464                size_bytes: 30,
465            },
466        ];
467
468        let entry_points = vec![EntryPoint {
469            path: PathBuf::from("/project/src/entry.ts"),
470            source: EntryPointSource::PackageJsonMain,
471        }];
472
473        let resolved_modules = vec![
474            ResolvedModule {
475                file_id: FileId(0),
476                path: PathBuf::from("/project/src/entry.ts"),
477                exports: vec![],
478                re_exports: vec![],
479                resolved_imports: vec![ResolvedImport {
480                    info: ImportInfo {
481                        source: "./utils".to_string(),
482                        imported_name: ImportedName::Named("foo".to_string()),
483                        local_name: "foo".to_string(),
484                        is_type_only: false,
485                        span: oxc_span::Span::new(0, 10),
486                    },
487                    target: ResolveResult::InternalModule(FileId(1)),
488                }],
489                resolved_dynamic_imports: vec![],
490                resolved_dynamic_patterns: vec![],
491                member_accesses: vec![],
492                whole_object_uses: vec![],
493                has_cjs_exports: false,
494                unused_import_bindings: vec![],
495            },
496            ResolvedModule {
497                file_id: FileId(1),
498                path: PathBuf::from("/project/src/utils.ts"),
499                exports: vec![
500                    ExportInfo {
501                        name: ExportName::Named("foo".to_string()),
502                        local_name: Some("foo".to_string()),
503                        is_type_only: false,
504                        is_public: false,
505                        span: oxc_span::Span::new(0, 20),
506                        members: vec![],
507                    },
508                    ExportInfo {
509                        name: ExportName::Named("bar".to_string()),
510                        local_name: Some("bar".to_string()),
511                        is_type_only: false,
512                        is_public: false,
513                        span: oxc_span::Span::new(21, 40),
514                        members: vec![],
515                    },
516                ],
517                re_exports: vec![],
518                resolved_imports: vec![],
519                resolved_dynamic_imports: vec![],
520                resolved_dynamic_patterns: vec![],
521                member_accesses: vec![],
522                whole_object_uses: vec![],
523                has_cjs_exports: false,
524                unused_import_bindings: vec![],
525            },
526            ResolvedModule {
527                file_id: FileId(2),
528                path: PathBuf::from("/project/src/unused.ts"),
529                exports: vec![ExportInfo {
530                    name: ExportName::Named("baz".to_string()),
531                    local_name: Some("baz".to_string()),
532                    is_type_only: false,
533                    is_public: false,
534                    span: oxc_span::Span::new(0, 15),
535                    members: vec![],
536                }],
537                re_exports: vec![],
538                resolved_imports: vec![],
539                resolved_dynamic_imports: vec![],
540                resolved_dynamic_patterns: vec![],
541                member_accesses: vec![],
542                whole_object_uses: vec![],
543                has_cjs_exports: false,
544                unused_import_bindings: vec![],
545            },
546        ];
547
548        ModuleGraph::build(&resolved_modules, &entry_points, &files)
549    }
550
551    #[test]
552    fn trace_used_export() {
553        let graph = build_test_graph();
554        let root = Path::new("/project");
555
556        let trace = trace_export(&graph, root, "src/utils.ts", "foo").unwrap();
557        assert!(trace.is_used);
558        assert!(trace.file_reachable);
559        assert_eq!(trace.direct_references.len(), 1);
560        assert_eq!(
561            trace.direct_references[0].from_file,
562            PathBuf::from("src/entry.ts")
563        );
564        assert_eq!(trace.direct_references[0].kind, "named import");
565    }
566
567    #[test]
568    fn trace_unused_export() {
569        let graph = build_test_graph();
570        let root = Path::new("/project");
571
572        let trace = trace_export(&graph, root, "src/utils.ts", "bar").unwrap();
573        assert!(!trace.is_used);
574        assert!(trace.file_reachable);
575        assert!(trace.direct_references.is_empty());
576    }
577
578    #[test]
579    fn trace_unreachable_file_export() {
580        let graph = build_test_graph();
581        let root = Path::new("/project");
582
583        let trace = trace_export(&graph, root, "src/unused.ts", "baz").unwrap();
584        assert!(!trace.is_used);
585        assert!(!trace.file_reachable);
586        assert!(trace.reason.contains("unreachable"));
587    }
588
589    #[test]
590    fn trace_nonexistent_export() {
591        let graph = build_test_graph();
592        let root = Path::new("/project");
593
594        let trace = trace_export(&graph, root, "src/utils.ts", "nonexistent");
595        assert!(trace.is_none());
596    }
597
598    #[test]
599    fn trace_nonexistent_file() {
600        let graph = build_test_graph();
601        let root = Path::new("/project");
602
603        let trace = trace_export(&graph, root, "src/nope.ts", "foo");
604        assert!(trace.is_none());
605    }
606
607    #[test]
608    fn trace_file_edges() {
609        let graph = build_test_graph();
610        let root = Path::new("/project");
611
612        let trace = trace_file(&graph, root, "src/entry.ts").unwrap();
613        assert!(trace.is_entry_point);
614        assert!(trace.is_reachable);
615        assert_eq!(trace.imports_from.len(), 1);
616        assert_eq!(trace.imports_from[0], PathBuf::from("src/utils.ts"));
617        assert!(trace.imported_by.is_empty());
618    }
619
620    #[test]
621    fn trace_file_imported_by() {
622        let graph = build_test_graph();
623        let root = Path::new("/project");
624
625        let trace = trace_file(&graph, root, "src/utils.ts").unwrap();
626        assert!(!trace.is_entry_point);
627        assert!(trace.is_reachable);
628        assert_eq!(trace.exports.len(), 2);
629        assert_eq!(trace.imported_by.len(), 1);
630        assert_eq!(trace.imported_by[0], PathBuf::from("src/entry.ts"));
631    }
632
633    #[test]
634    fn trace_unreachable_file() {
635        let graph = build_test_graph();
636        let root = Path::new("/project");
637
638        let trace = trace_file(&graph, root, "src/unused.ts").unwrap();
639        assert!(!trace.is_reachable);
640        assert!(!trace.is_entry_point);
641        assert!(trace.imported_by.is_empty());
642    }
643
644    #[test]
645    fn trace_dependency_used() {
646        // Build a graph with npm package usage
647        let files = vec![DiscoveredFile {
648            id: FileId(0),
649            path: PathBuf::from("/project/src/app.ts"),
650            size_bytes: 100,
651        }];
652        let entry_points = vec![EntryPoint {
653            path: PathBuf::from("/project/src/app.ts"),
654            source: EntryPointSource::PackageJsonMain,
655        }];
656        let resolved_modules = vec![ResolvedModule {
657            file_id: FileId(0),
658            path: PathBuf::from("/project/src/app.ts"),
659            exports: vec![],
660            re_exports: vec![],
661            resolved_imports: vec![ResolvedImport {
662                info: ImportInfo {
663                    source: "lodash".to_string(),
664                    imported_name: ImportedName::Named("get".to_string()),
665                    local_name: "get".to_string(),
666                    is_type_only: false,
667                    span: oxc_span::Span::new(0, 10),
668                },
669                target: ResolveResult::NpmPackage("lodash".to_string()),
670            }],
671            resolved_dynamic_imports: vec![],
672            resolved_dynamic_patterns: vec![],
673            member_accesses: vec![],
674            whole_object_uses: vec![],
675            has_cjs_exports: false,
676            unused_import_bindings: vec![],
677        }];
678
679        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
680        let root = Path::new("/project");
681
682        let trace = trace_dependency(&graph, root, "lodash");
683        assert!(trace.is_used);
684        assert_eq!(trace.import_count, 1);
685        assert_eq!(trace.imported_by[0], PathBuf::from("src/app.ts"));
686    }
687
688    #[test]
689    fn trace_dependency_unused() {
690        let files = vec![DiscoveredFile {
691            id: FileId(0),
692            path: PathBuf::from("/project/src/app.ts"),
693            size_bytes: 100,
694        }];
695        let entry_points = vec![EntryPoint {
696            path: PathBuf::from("/project/src/app.ts"),
697            source: EntryPointSource::PackageJsonMain,
698        }];
699        let resolved_modules = vec![ResolvedModule {
700            file_id: FileId(0),
701            path: PathBuf::from("/project/src/app.ts"),
702            exports: vec![],
703            re_exports: vec![],
704            resolved_imports: vec![],
705            resolved_dynamic_imports: vec![],
706            resolved_dynamic_patterns: vec![],
707            member_accesses: vec![],
708            whole_object_uses: vec![],
709            has_cjs_exports: false,
710            unused_import_bindings: vec![],
711        }];
712
713        let graph = ModuleGraph::build(&resolved_modules, &entry_points, &files);
714        let root = Path::new("/project");
715
716        let trace = trace_dependency(&graph, root, "nonexistent-pkg");
717        assert!(!trace.is_used);
718        assert_eq!(trace.import_count, 0);
719        assert!(trace.imported_by.is_empty());
720    }
721
722    #[test]
723    fn trace_clone_finds_matching_group() {
724        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
725        let report = DuplicationReport {
726            clone_groups: vec![CloneGroup {
727                instances: vec![
728                    CloneInstance {
729                        file: PathBuf::from("/project/src/a.ts"),
730                        start_line: 10,
731                        end_line: 20,
732                        start_col: 0,
733                        end_col: 0,
734                        fragment: "fn foo() {}".to_string(),
735                    },
736                    CloneInstance {
737                        file: PathBuf::from("/project/src/b.ts"),
738                        start_line: 5,
739                        end_line: 15,
740                        start_col: 0,
741                        end_col: 0,
742                        fragment: "fn foo() {}".to_string(),
743                    },
744                ],
745                token_count: 60,
746                line_count: 11,
747            }],
748            clone_families: vec![],
749            stats: DuplicationStats {
750                total_files: 2,
751                files_with_clones: 2,
752                total_lines: 100,
753                duplicated_lines: 22,
754                total_tokens: 200,
755                duplicated_tokens: 120,
756                clone_groups: 1,
757                clone_instances: 2,
758                duplication_percentage: 22.0,
759            },
760        };
761        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 15);
762        assert!(trace.matched_instance.is_some());
763        assert_eq!(trace.clone_groups.len(), 1);
764        assert_eq!(trace.clone_groups[0].instances.len(), 2);
765    }
766
767    #[test]
768    fn trace_clone_no_match() {
769        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
770        let report = DuplicationReport {
771            clone_groups: vec![CloneGroup {
772                instances: vec![CloneInstance {
773                    file: PathBuf::from("/project/src/a.ts"),
774                    start_line: 10,
775                    end_line: 20,
776                    start_col: 0,
777                    end_col: 0,
778                    fragment: "fn foo() {}".to_string(),
779                }],
780                token_count: 60,
781                line_count: 11,
782            }],
783            clone_families: vec![],
784            stats: DuplicationStats {
785                total_files: 1,
786                files_with_clones: 1,
787                total_lines: 50,
788                duplicated_lines: 11,
789                total_tokens: 100,
790                duplicated_tokens: 60,
791                clone_groups: 1,
792                clone_instances: 1,
793                duplication_percentage: 22.0,
794            },
795        };
796        let trace = trace_clone(&report, Path::new("/project"), "src/a.ts", 25);
797        assert!(trace.matched_instance.is_none());
798        assert!(trace.clone_groups.is_empty());
799    }
800
801    #[test]
802    fn trace_clone_line_boundary() {
803        use crate::duplicates::{CloneGroup, CloneInstance, DuplicationReport, DuplicationStats};
804        let report = DuplicationReport {
805            clone_groups: vec![CloneGroup {
806                instances: vec![
807                    CloneInstance {
808                        file: PathBuf::from("/project/src/a.ts"),
809                        start_line: 10,
810                        end_line: 20,
811                        start_col: 0,
812                        end_col: 0,
813                        fragment: "code".to_string(),
814                    },
815                    CloneInstance {
816                        file: PathBuf::from("/project/src/b.ts"),
817                        start_line: 1,
818                        end_line: 11,
819                        start_col: 0,
820                        end_col: 0,
821                        fragment: "code".to_string(),
822                    },
823                ],
824                token_count: 50,
825                line_count: 11,
826            }],
827            clone_families: vec![],
828            stats: DuplicationStats {
829                total_files: 2,
830                files_with_clones: 2,
831                total_lines: 100,
832                duplicated_lines: 22,
833                total_tokens: 200,
834                duplicated_tokens: 100,
835                clone_groups: 1,
836                clone_instances: 2,
837                duplication_percentage: 22.0,
838            },
839        };
840        let root = Path::new("/project");
841        assert!(
842            trace_clone(&report, root, "src/a.ts", 10)
843                .matched_instance
844                .is_some()
845        );
846        assert!(
847            trace_clone(&report, root, "src/a.ts", 20)
848                .matched_instance
849                .is_some()
850        );
851        assert!(
852            trace_clone(&report, root, "src/a.ts", 21)
853                .matched_instance
854                .is_none()
855        );
856    }
857}