Skip to main content

fallow_api/runtime/
trace.rs

1use fallow_engine::session::AnalysisSession;
2use fallow_types::duplicates::DuplicationReport;
3use rustc_hash::FxHashSet;
4
5use crate::{
6    ProgrammaticAnalysisContext, ProgrammaticError, TraceCloneOptions,
7    TraceCloneProgrammaticOutput, TraceCloneTarget, TraceDependencyOptions,
8    TraceDependencyProgrammaticOutput, TraceErrorOptions, TraceErrorProgrammaticOutput,
9    TraceExportOptions, TraceExportProgrammaticOutput, TraceExportTargetOutput, TraceFileOptions,
10    TraceFileProgrammaticOutput, TraceImportPathOptions, TraceImportPathProgrammaticOutput,
11};
12
13use super::{ProgrammaticResult, duplication, resolve_programmatic_analysis_context};
14
15struct TraceArtifacts {
16    graph: fallow_engine::module_graph::RetainedModuleGraph,
17    script_used_packages: FxHashSet<String>,
18    trace_provenance: fallow_engine::trace::TraceProvenance,
19}
20
21/// Trace why an export is considered used or unused.
22///
23/// # Errors
24///
25/// Returns a structured programmatic error for invalid options, config load
26/// failures, graph construction failures, or missing trace targets.
27pub fn run_trace_export(
28    options: &TraceExportOptions,
29) -> ProgrammaticResult<TraceExportProgrammaticOutput> {
30    validate_non_empty("file", &options.file)?;
31    validate_non_empty("export_name", &options.export_name)?;
32    let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
33    resolved.install(|| {
34        let session = load_trace_session(&resolved)?;
35        let artifacts = trace_artifacts(&session)?;
36        // Resolve a top-level export first; on a miss fall back to a class /
37        // enum / store member trace so the MCP tool and Code Mode match the
38        // CLI's `--trace FILE:MEMBER` behavior instead of a hard not-found
39        // (issue #1744).
40        let output = if let Some(export) = fallow_engine::trace::trace_export(
41            &artifacts.graph,
42            session.root(),
43            &options.file,
44            &options.export_name,
45        ) {
46            TraceExportTargetOutput::Export(export)
47        } else if let Some(member) = fallow_engine::trace::trace_class_member(
48            &artifacts.graph,
49            session.root(),
50            &options.file,
51            &options.export_name,
52        ) {
53            TraceExportTargetOutput::Member(member)
54        } else {
55            return Err(ProgrammaticError::new(
56                format!(
57                    "export or member '{}' not found in '{}'",
58                    options.export_name, options.file
59                ),
60                2,
61            )
62            .with_code("FALLOW_TRACE_TARGET_NOT_FOUND")
63            .with_help(
64                "The name is neither a top-level export nor a class / enum / store member of this \
65                 file. Run trace_file on the file to list its exports, or project_info for the \
66                 project symbol set; confirm the file path is project-relative.",
67            )
68            .with_context("trace_export"));
69        };
70        Ok(TraceExportProgrammaticOutput { output })
71    })
72}
73
74/// Trace all graph edges for a file.
75///
76/// # Errors
77///
78/// Returns a structured programmatic error for invalid options, config load
79/// failures, graph construction failures, or missing trace targets.
80pub fn run_trace_file(
81    options: &TraceFileOptions,
82) -> ProgrammaticResult<TraceFileProgrammaticOutput> {
83    validate_non_empty("file", &options.file)?;
84    let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
85    resolved.install(|| {
86        let session = load_trace_session(&resolved)?;
87        let artifacts = trace_artifacts(&session)?;
88        let mut output =
89            fallow_engine::trace::trace_file(&artifacts.graph, session.root(), &options.file)
90                .ok_or_else(|| {
91                    ProgrammaticError::new(
92                        format!("file '{}' not found in module graph", options.file),
93                        2,
94                    )
95                    .with_code("FALLOW_TRACE_TARGET_NOT_FOUND")
96                    .with_help(
97                        "The file is not in the analyzed module graph. Run project_info to list \
98                         discovered files; the path must be project-relative and not excluded by \
99                         ignore patterns or outside the analyzed roots.",
100                    )
101                    .with_context("trace_file")
102                })?;
103        output.sources = artifacts.trace_provenance.file_sources(&output.file);
104        Ok(TraceFileProgrammaticOutput { output })
105    })
106}
107
108/// Trace the shortest import path between two modules.
109///
110/// An unreachable pair is a RESULT, not an error: the output reports
111/// `reachable: false` with zero hops. A missing or ambiguous endpoint is an error.
112///
113/// # Errors
114///
115/// Returns a structured programmatic error for invalid options, config load
116/// failures, graph construction failures, or a missing or ambiguous endpoint.
117pub fn run_trace_import_path(
118    options: &TraceImportPathOptions,
119) -> ProgrammaticResult<TraceImportPathProgrammaticOutput> {
120    validate_non_empty("from", &options.from)?;
121    validate_non_empty("to", &options.to)?;
122    let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
123    resolved.install(|| {
124        let session = load_trace_session(&resolved)?;
125        let artifacts = trace_artifacts(&session)?;
126        let trace = if options.eager_only {
127            fallow_engine::trace::trace_eager_import_path
128        } else {
129            fallow_engine::trace::trace_import_path
130        };
131        let output = trace(
132            &artifacts.graph,
133            session.root(),
134            &options.from,
135            &options.to,
136        )
137        .map_err(|endpoint| {
138            let label = endpoint.label();
139            let value = match endpoint {
140                fallow_engine::trace::ImportPathEndpoint::From
141                | fallow_engine::trace::ImportPathEndpoint::AmbiguousFrom => &options.from,
142                fallow_engine::trace::ImportPathEndpoint::To
143                | fallow_engine::trace::ImportPathEndpoint::AmbiguousTo => &options.to,
144            };
145            if endpoint.is_ambiguous() {
146                return ProgrammaticError::new(format!("'{value}' ({label}) matches multiple modules"), 2)
147                    .with_code("FALLOW_TRACE_TARGET_AMBIGUOUS")
148                    .with_help("Use the full project-relative path; run project_info to list discovered files.")
149                    .with_context("trace_import_path");
150            }
151            ProgrammaticError::new(format!("'{value}' ({label}) not found in module graph"), 2)
152                .with_code("FALLOW_TRACE_TARGET_NOT_FOUND")
153                .with_help(
154                    "The module is not in the analyzed module graph. Run project_info to list \
155                 discovered files; both paths must be project-relative and not excluded by \
156                 ignore patterns or outside the analyzed roots.",
157                )
158                .with_context("trace_import_path")
159        })?;
160        Ok(TraceImportPathProgrammaticOutput { output })
161    })
162}
163
164/// Resolve a runtime stack trace's frames against the project graph.
165///
166/// A trace in which nothing resolves is a RESULT, not an error: the output
167/// reports every frame with the origin and resolution that explain why. Only
168/// an empty trace argument, a config load failure, or a failed analysis is an
169/// error.
170///
171/// # Errors
172///
173/// Returns a structured programmatic error for an empty or oversized trace,
174/// config load failures, or graph construction failures.
175pub fn run_trace_error(
176    options: &TraceErrorOptions,
177) -> ProgrammaticResult<TraceErrorProgrammaticOutput> {
178    if options.trace.trim().is_empty() {
179        // Typed like the oversized-trace refusal below rather than through
180        // `validate_non_empty`: both are the same tool refusing the same
181        // argument, so both carry a `code`, a `help` and a `context`.
182        return Err(ProgrammaticError::new("trace must not be empty", 2)
183            .with_code("FALLOW_INVALID_TRACE_OPTIONS")
184            .with_help(
185                "Paste the stack trace text into `trace`, as your runtime printed it. \
186                 trace_error resolves frames against the project graph, so it has nothing \
187                 to resolve without them.",
188            )
189            .with_context("trace_error"));
190    }
191    if options.trace.len() as u64 > fallow_engine::trace_error::MAX_STACK_TRACE_BYTES {
192        let limit = fallow_engine::trace_error::MAX_STACK_TRACE_BYTES;
193        return Err(ProgrammaticError::new(
194            format!("stack trace exceeds the {limit}-byte limit"),
195            2,
196        )
197        .with_code("FALLOW_INVALID_TRACE_OPTIONS")
198        .with_help(
199            "A stack trace is a handful of kilobytes. Pass the trace itself rather than a \
200             redirected log file, and cut it to the frames that matter.",
201        )
202        .with_context("trace_error"));
203    }
204    let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
205    resolved.install(|| {
206        let session = load_trace_session(&resolved)?;
207        let output = fallow_engine::trace_error::trace_error_with_session(
208            &session,
209            &options.trace,
210            options.source.clone(),
211        )
212        .map_err(|err| {
213            ProgrammaticError::new(format!("stack-trace resolution failed: {err}"), 2)
214                .with_code("FALLOW_ANALYSIS_FAILED")
215                .with_context("trace_error")
216        })?;
217        Ok(TraceErrorProgrammaticOutput { output })
218    })
219}
220
221/// Trace where a dependency is used.
222///
223/// # Errors
224///
225/// Returns a structured programmatic error for invalid options, config load, or
226/// graph construction failures.
227pub fn run_trace_dependency(
228    options: &TraceDependencyOptions,
229) -> ProgrammaticResult<TraceDependencyProgrammaticOutput> {
230    validate_non_empty("package_name", &options.package_name)?;
231    let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
232    resolved.install(|| {
233        let session = load_trace_session(&resolved)?;
234        let artifacts = trace_artifacts(&session)?;
235        let mut output = fallow_engine::trace::trace_dependency(
236            &artifacts.graph,
237            session.root(),
238            &options.package_name,
239            &artifacts.script_used_packages,
240        );
241        output.sources = artifacts
242            .trace_provenance
243            .dependency_sources(&options.package_name);
244        Ok(TraceDependencyProgrammaticOutput { output })
245    })
246}
247
248/// Trace duplicate-code groups by location or stable fingerprint.
249///
250/// # Errors
251///
252/// Returns a structured programmatic error for invalid options, config load
253/// failures, duplicate detection failures, or missing trace targets.
254pub fn run_trace_clone(
255    options: &TraceCloneOptions,
256) -> ProgrammaticResult<TraceCloneProgrammaticOutput> {
257    validate_trace_clone_target(&options.target)?;
258    let resolved = resolve_programmatic_analysis_context(&options.duplication.analysis)?;
259    resolved.install(|| {
260        resolved.ensure_not_cancelled("config load and file discovery")?;
261        let session = duplication::load_duplication_session(&options.duplication, &resolved)?;
262        resolved.ensure_not_cancelled("duplication detection")?;
263        let dupes_config =
264            duplication::build_dupes_config(&options.duplication, &session.config().duplicates);
265        let cache_dir = (!resolved.no_cache).then_some(session.config().cache_dir.as_path());
266        let report = session
267            .find_duplicates_with_defaults(&dupes_config, cache_dir)
268            .report;
269        // Duplication detection is infallible, so the cancelled run has to be
270        // reported before the report is traced as a complete one.
271        resolved.ensure_not_cancelled("the clone trace")?;
272        let (trace, not_found) = match &options.target {
273            TraceCloneTarget::Location { file, line } => (
274                fallow_engine::trace::trace_clone(&report, session.root(), file, *line),
275                format!("no clone found at {file}:{line}"),
276            ),
277            TraceCloneTarget::Fingerprint(fingerprint) => (
278                fallow_engine::trace::trace_clone_by_fingerprint(
279                    &report,
280                    session.root(),
281                    fingerprint,
282                ),
283                format!("no clone group with fingerprint {fingerprint}"),
284            ),
285        };
286        if trace.matched_instance.is_none() {
287            return Err(ProgrammaticError::new(not_found, 2)
288                .with_code("FALLOW_TRACE_TARGET_NOT_FOUND")
289                .with_help(
290                    "No clone matched. Run find_dupes to list clone groups and their fingerprints; \
291                     a location must fall inside a reported clone instance, and a fingerprint must \
292                     be a find_dupes clone_groups[].fingerprint (a dup:<id> value).",
293                )
294                .with_context("trace_clone"));
295        }
296        Ok(TraceCloneProgrammaticOutput { output: trace })
297    })
298}
299
300/// Exercise the retained-graph trace family and compact JSON boundary without
301/// repeating project discovery, parsing, or graph construction.
302///
303/// # Errors
304///
305/// Returns a structured error when a fixture target is missing or compact JSON
306/// serialization fails.
307#[doc(hidden)]
308#[allow(
309    clippy::implicit_hasher,
310    reason = "the engine trace boundary intentionally accepts the workspace-standard FxHashSet"
311)]
312pub fn benchmark_trace_graph_family_compact_json(
313    graph: &fallow_engine::module_graph::RetainedModuleGraph,
314    root: &std::path::Path,
315    script_used_packages: &FxHashSet<String>,
316) -> ProgrammaticResult<(usize, usize, usize, usize, usize)> {
317    let export =
318        fallow_engine::trace::trace_export(graph, root, "src/000-shared.ts", "sharedValue")
319            .ok_or_else(|| benchmark_trace_target_missing("src/000-shared.ts:sharedValue"))?;
320    let export_reference_count = export.direct_references.len();
321    let export_json =
322        crate::serialize_trace_export_programmatic_json(TraceExportProgrammaticOutput {
323            output: TraceExportTargetOutput::Export(export),
324        })?;
325
326    let file = fallow_engine::trace::trace_file(graph, root, "src/000-shared.ts")
327        .ok_or_else(|| benchmark_trace_target_missing("src/000-shared.ts"))?;
328    let file_export_count = file.exports.len();
329    let file_imported_by_count = file.imported_by.len();
330    let file_json = crate::serialize_trace_file_programmatic_json(TraceFileProgrammaticOutput {
331        output: file,
332    })?;
333
334    let dependency =
335        fallow_engine::trace::trace_dependency(graph, root, "trace-package", script_used_packages);
336    let dependency_import_count = dependency.import_count;
337    let dependency_json =
338        crate::serialize_trace_dependency_programmatic_json(TraceDependencyProgrammaticOutput {
339            output: dependency,
340        })?;
341
342    let rendered_bytes = compact_json_len(&[export_json, file_json, dependency_json])?;
343    Ok((
344        export_reference_count,
345        file_export_count,
346        file_imported_by_count,
347        dependency_import_count,
348        rendered_bytes,
349    ))
350}
351
352/// Stable facts returned by the clone trace benchmark boundary.
353#[doc(hidden)]
354#[derive(Debug, PartialEq, Eq)]
355pub struct TraceCloneBenchmarkResult {
356    /// Location identity returned by the location trace.
357    pub location_file: std::path::PathBuf,
358    /// Location line returned by the location trace.
359    pub location_line: usize,
360    /// Group fingerprint returned by the location trace.
361    pub location_fingerprint: String,
362    /// Group fingerprint returned by the fingerprint trace.
363    pub fingerprint_fingerprint: String,
364    /// Groups returned by the location trace.
365    pub location_group_count: usize,
366    /// Groups returned by the fingerprint trace.
367    pub fingerprint_group_count: usize,
368    /// Instances returned by the location trace.
369    pub location_instance_count: usize,
370    /// Instances returned by the fingerprint trace.
371    pub fingerprint_instance_count: usize,
372    /// Total compact JSON bytes for both trace responses.
373    pub rendered_bytes: usize,
374}
375
376/// Exercise both supported clone trace identities from one retained report.
377///
378/// # Errors
379///
380/// Returns a structured error when either identity misses or compact JSON
381/// serialization fails.
382#[doc(hidden)]
383pub fn benchmark_trace_clone_compact_json(
384    report: &DuplicationReport,
385    root: &std::path::Path,
386    file: &str,
387    line: usize,
388    fingerprint: &str,
389) -> ProgrammaticResult<TraceCloneBenchmarkResult> {
390    let location_trace = fallow_engine::trace::trace_clone(report, root, file, line);
391    let matched_location = location_trace
392        .matched_instance
393        .as_ref()
394        .ok_or_else(|| benchmark_trace_target_missing(&format!("{file}:{line}")))?;
395    let location_file = matched_location.file.clone();
396    let location_line = matched_location.start_line;
397    let location_fingerprint = location_trace
398        .clone_groups
399        .first()
400        .map(|group| group.fingerprint.clone())
401        .ok_or_else(|| benchmark_trace_target_missing(&format!("{file}:{line}")))?;
402    let location_group_count = location_trace.clone_groups.len();
403    let location_instance_count = location_trace
404        .clone_groups
405        .iter()
406        .map(|group| group.instances.len())
407        .sum();
408    let location_json =
409        crate::serialize_trace_clone_programmatic_json(TraceCloneProgrammaticOutput {
410            output: location_trace,
411        })?;
412
413    let fingerprint_trace =
414        fallow_engine::trace::trace_clone_by_fingerprint(report, root, fingerprint);
415    if fingerprint_trace.matched_instance.is_none() {
416        return Err(benchmark_trace_target_missing(fingerprint));
417    }
418    let fingerprint_fingerprint = fingerprint_trace
419        .clone_groups
420        .first()
421        .map(|group| group.fingerprint.clone())
422        .ok_or_else(|| benchmark_trace_target_missing(fingerprint))?;
423    let fingerprint_group_count = fingerprint_trace.clone_groups.len();
424    let fingerprint_instance_count = fingerprint_trace
425        .clone_groups
426        .iter()
427        .map(|group| group.instances.len())
428        .sum();
429    let fingerprint_json =
430        crate::serialize_trace_clone_programmatic_json(TraceCloneProgrammaticOutput {
431            output: fingerprint_trace,
432        })?;
433
434    let rendered_bytes = compact_json_len(&[location_json, fingerprint_json])?;
435    Ok(TraceCloneBenchmarkResult {
436        location_file,
437        location_line,
438        location_fingerprint,
439        fingerprint_fingerprint,
440        location_group_count,
441        fingerprint_group_count,
442        location_instance_count,
443        fingerprint_instance_count,
444        rendered_bytes,
445    })
446}
447
448fn compact_json_len(values: &[serde_json::Value]) -> ProgrammaticResult<usize> {
449    serde_json::to_vec(values)
450        .map(|json| json.len())
451        .map_err(|err| {
452            ProgrammaticError::new(
453                format!("failed to serialize benchmark trace JSON: {err}"),
454                2,
455            )
456            .with_code("FALLOW_SERIALIZE_BENCHMARK_TRACE")
457            .with_context("benchmark_trace")
458        })
459}
460
461fn benchmark_trace_target_missing(target: &str) -> ProgrammaticError {
462    ProgrammaticError::new(format!("benchmark trace target not found: {target}"), 2)
463        .with_code("FALLOW_BENCHMARK_TRACE_TARGET_NOT_FOUND")
464        .with_context("benchmark_trace")
465}
466
467fn validate_non_empty(field: &str, value: &str) -> ProgrammaticResult<()> {
468    if value.trim().is_empty() {
469        return Err(
470            ProgrammaticError::new(format!("{field} must not be empty"), 2)
471                .with_code("FALLOW_INVALID_TRACE_OPTIONS")
472                .with_context(field.to_string()),
473        );
474    }
475    Ok(())
476}
477
478fn validate_trace_clone_target(target: &TraceCloneTarget) -> ProgrammaticResult<()> {
479    match target {
480        TraceCloneTarget::Location { file, line } => {
481            validate_non_empty("file", file)?;
482            if *line == 0 {
483                return Err(ProgrammaticError::new("line must be greater than 0", 2)
484                    .with_code("FALLOW_INVALID_TRACE_OPTIONS")
485                    .with_context("trace_clone.line"));
486            }
487        }
488        TraceCloneTarget::Fingerprint(fingerprint) => {
489            validate_non_empty("fingerprint", fingerprint)?;
490        }
491    }
492    Ok(())
493}
494
495fn load_trace_session(
496    resolved: &ProgrammaticAnalysisContext,
497) -> ProgrammaticResult<AnalysisSession> {
498    super::dead_code::load_dead_code_session(
499        &super::dead_code::default_dead_code_options_for_context(resolved),
500        resolved,
501    )
502}
503
504fn trace_artifacts(session: &AnalysisSession) -> ProgrammaticResult<TraceArtifacts> {
505    let artifacts = session
506        .analyze_dead_code_with_session_artifacts(false, true, None)
507        .map_err(|err| {
508            super::dead_code::map_engine_error(
509                &err,
510                "trace analysis failed",
511                "FALLOW_TRACE_FAILED",
512                "trace",
513            )
514        })?;
515    let graph = artifacts.analysis.graph.ok_or_else(|| {
516        ProgrammaticError::new("trace requires a retained module graph", 2)
517            .with_code("FALLOW_TRACE_GRAPH_UNAVAILABLE")
518            .with_context("trace.graph")
519    })?;
520    Ok(TraceArtifacts {
521        graph,
522        script_used_packages: artifacts.analysis.script_used_packages,
523        trace_provenance: artifacts.analysis.trace_provenance,
524    })
525}
526
527#[cfg(test)]
528mod benchmark_tests {
529    use std::fmt::Write as _;
530    use std::fs;
531
532    use fallow_engine::duplicates::CloneFingerprintSet;
533
534    use super::*;
535
536    const FIXTURE_SIZE: usize = 4;
537
538    fn write_file(root: &std::path::Path, path: &str, source: impl AsRef<str>) {
539        let path = root.join(path);
540        fs::create_dir_all(path.parent().expect("fixture file has parent"))
541            .expect("fixture directory is created");
542        fs::write(path, source.as_ref()).expect("fixture file is written");
543    }
544
545    #[test]
546    fn graph_family_benchmark_boundary_uses_only_retained_artifacts() {
547        let temp_dir = tempfile::TempDir::new().expect("temporary project is created");
548        let root = temp_dir.path().to_path_buf();
549        write_file(
550            &root,
551            "package.json",
552            r#"{"name":"trace-test","type":"module","main":"src/index.ts"}"#,
553        );
554        write_file(
555            &root,
556            "src/000-shared.ts",
557            "export const sharedValue = 42;\n",
558        );
559
560        let mut index_source = String::new();
561        for index in 0..FIXTURE_SIZE {
562            write_file(
563                &root,
564                &format!("src/consumer{index}.ts"),
565                format!(
566                    "import {{ sharedValue }} from './000-shared';\nimport {{ traceHelper }} from 'trace-package';\nexport const value{index} = traceHelper(sharedValue + {index});\n"
567                ),
568            );
569            writeln!(
570                index_source,
571                "import {{ value{index} }} from './consumer{index}';\nconsole.log(value{index});"
572            )
573            .expect("index source is built");
574        }
575        write_file(&root, "src/index.ts", index_source);
576
577        let session = AnalysisSession::load(&root, None).expect("trace session loads");
578        let target = session
579            .files()
580            .iter()
581            .find(|file| file.path.ends_with("src/000-shared.ts"))
582            .expect("trace target is discovered");
583        assert_eq!(
584            target.id.0, 0,
585            "the retained trace target must precede every non-matching importer"
586        );
587        let trace_root = session.root().to_path_buf();
588        let artifacts = session
589            .analyze_dead_code_with_artifacts(false, true)
590            .expect("trace graph analysis succeeds");
591        drop(session);
592        temp_dir.close().expect("temporary project is removed");
593
594        let result = benchmark_trace_graph_family_compact_json(
595            artifacts.graph.as_ref().expect("trace graph is retained"),
596            &trace_root,
597            &artifacts.script_used_packages,
598        )
599        .expect("retained trace graph serializes without project IO");
600        assert_eq!(result.0, FIXTURE_SIZE);
601        assert_eq!(result.1, 1);
602        assert_eq!(result.2, FIXTURE_SIZE);
603        assert_eq!(result.3, FIXTURE_SIZE);
604        assert!(result.4 > 0);
605    }
606
607    #[test]
608    fn clone_benchmark_boundary_uses_only_the_retained_report() {
609        let temp_dir = tempfile::TempDir::new().expect("temporary project is created");
610        let root = temp_dir.path().to_path_buf();
611        write_file(
612            &root,
613            "package.json",
614            r#"{"name":"trace-clone-test","type":"module"}"#,
615        );
616        for index in 0..FIXTURE_SIZE {
617            write_file(
618                &root,
619                &format!("src/clone{index}.ts"),
620                format!(
621                    "export function normalizeRecords(records: Array<{{ active: boolean; value: number }}>) {{\n  const active = records.filter((record) => record.active);\n  const values = active.map((record) => record.value);\n  const total = values.reduce((sum, value) => sum + value, 0);\n  const average = values.length === 0 ? 0 : total / values.length;\n  const maximum = values.reduce((current, value) => Math.max(current, value), 0);\n  return {{ total, average, maximum, count: values.length }};\n}}\n\nexport const cloneId = {index};\n"
622                ),
623            );
624        }
625
626        let session = AnalysisSession::load(&root, None).expect("clone session loads");
627        let trace_root = session.root().to_path_buf();
628        let mut config = session.config().duplicates.clone();
629        config.min_tokens = 35;
630        config.min_lines = 5;
631        config.min_occurrences = FIXTURE_SIZE;
632        let report = session.find_duplicates_with_defaults(&config, None).report;
633        let group = report
634            .clone_groups
635            .iter()
636            .max_by_key(|group| group.instances.len())
637            .expect("clone group exists");
638        let target = group.instances.last().expect("clone instance exists");
639        let target_file = target
640            .file
641            .strip_prefix(&trace_root)
642            .expect("clone path is project-relative")
643            .to_string_lossy()
644            .replace('\\', "/");
645        let target_line = target.start_line;
646        let expected_fingerprint =
647            CloneFingerprintSet::from_groups(&report.clone_groups).fingerprint_for_group(group);
648        drop(session);
649        temp_dir.close().expect("temporary project is removed");
650
651        let result = benchmark_trace_clone_compact_json(
652            &report,
653            &trace_root,
654            &target_file,
655            target_line,
656            &expected_fingerprint,
657        )
658        .expect("retained clone report serializes without project IO");
659        assert_eq!(result.location_file, std::path::PathBuf::from(&target_file));
660        assert_eq!(result.location_line, target_line);
661        assert_eq!(result.location_fingerprint, expected_fingerprint);
662        assert_eq!(result.fingerprint_fingerprint, expected_fingerprint);
663        assert_eq!(result.location_group_count, 1);
664        assert_eq!(result.fingerprint_group_count, 1);
665        assert_eq!(result.location_instance_count, FIXTURE_SIZE);
666        assert_eq!(result.fingerprint_instance_count, FIXTURE_SIZE);
667        assert!(result.rendered_bytes > 0);
668    }
669}