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