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