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