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