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