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