1use fallow_engine::session::AnalysisSession;
2use fallow_types::duplicates::DuplicationReport;
3use rustc_hash::FxHashSet;
4
5use crate::{
6 DependencyUsageQuery, 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};
14use crate::analysis_context::validate_workspace_selection;
15
16struct TraceArtifacts {
17 graph: fallow_engine::module_graph::RetainedModuleGraph,
18 script_used_packages: FxHashSet<String>,
19 trace_provenance: fallow_engine::trace::TraceProvenance,
20 results: fallow_types::results::AnalysisResults,
23}
24
25pub fn run_trace_export(
35 options: &TraceExportOptions,
36) -> ProgrammaticResult<TraceExportProgrammaticOutput> {
37 validate_non_empty("file", &options.file)?;
38 validate_non_empty("export_name", &options.export_name)?;
39 let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
40 super::type_aware_config::reject_type_aware_trace(&options.analysis.type_aware, &resolved)?;
41 resolved.install(|| {
42 let session = load_trace_session(&resolved)?;
43 let artifacts = trace_artifacts(&session)?;
44 let output = if let Some(export) = fallow_engine::trace::trace_export(
49 &artifacts.graph,
50 session.root(),
51 &options.file,
52 &options.export_name,
53 ) {
54 TraceExportTargetOutput::Export(export)
55 } else if let Some(member) = fallow_engine::trace::trace_class_member(
56 &artifacts.graph,
57 session.root(),
58 &options.file,
59 &options.export_name,
60 ) {
61 TraceExportTargetOutput::Member(member)
62 } else {
63 return Err(ProgrammaticError::new(
64 format!(
65 "export or member '{}' not found in '{}'",
66 options.export_name, options.file
67 ),
68 2,
69 )
70 .with_code("FALLOW_TRACE_TARGET_NOT_FOUND")
71 .with_help(
72 "The name is neither a top-level export nor a class / enum / store member of this \
73 file. Run trace_file on the file to list its exports, or project_info for the \
74 project symbol set; confirm the file path is project-relative.",
75 )
76 .with_context("trace_export"));
77 };
78 Ok(TraceExportProgrammaticOutput { output })
79 })
80}
81
82pub fn run_trace_file(
89 options: &TraceFileOptions,
90) -> ProgrammaticResult<TraceFileProgrammaticOutput> {
91 validate_non_empty("file", &options.file)?;
92 let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
93 resolved.install(|| {
94 let session = load_trace_session(&resolved)?;
95 let artifacts = trace_artifacts(&session)?;
96 let mut output =
97 fallow_engine::trace::trace_file(&artifacts.graph, session.root(), &options.file)
98 .ok_or_else(|| {
99 ProgrammaticError::new(
100 format!("file '{}' not found in module graph", options.file),
101 2,
102 )
103 .with_code("FALLOW_TRACE_TARGET_NOT_FOUND")
104 .with_help(
105 "The file is not in the analyzed module graph. Run project_info to list \
106 discovered files; the path must be project-relative and not excluded by \
107 ignore patterns or outside the analyzed roots.",
108 )
109 .with_context("trace_file")
110 })?;
111 output.sources = artifacts.trace_provenance.file_sources(&output.file);
112 Ok(TraceFileProgrammaticOutput { output })
113 })
114}
115
116pub fn run_trace_import_path(
126 options: &TraceImportPathOptions,
127) -> ProgrammaticResult<TraceImportPathProgrammaticOutput> {
128 validate_non_empty("from", &options.from)?;
129 validate_non_empty("to", &options.to)?;
130 let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
131 resolved.install(|| {
132 let session = load_trace_session(&resolved)?;
133 let artifacts = trace_artifacts(&session)?;
134 let trace = if options.eager_only {
135 fallow_engine::trace::trace_eager_import_path
136 } else {
137 fallow_engine::trace::trace_import_path
138 };
139 let output = trace(
140 &artifacts.graph,
141 session.root(),
142 &options.from,
143 &options.to,
144 )
145 .map_err(|endpoint| {
146 let label = endpoint.label();
147 let value = match endpoint {
148 fallow_engine::trace::ImportPathEndpoint::From
149 | fallow_engine::trace::ImportPathEndpoint::AmbiguousFrom => &options.from,
150 fallow_engine::trace::ImportPathEndpoint::To
151 | fallow_engine::trace::ImportPathEndpoint::AmbiguousTo => &options.to,
152 };
153 if endpoint.is_ambiguous() {
154 return ProgrammaticError::new(format!("'{value}' ({label}) matches multiple modules"), 2)
155 .with_code("FALLOW_TRACE_TARGET_AMBIGUOUS")
156 .with_help("Use the full project-relative path; run project_info to list discovered files.")
157 .with_context("trace_import_path");
158 }
159 ProgrammaticError::new(format!("'{value}' ({label}) not found in module graph"), 2)
160 .with_code("FALLOW_TRACE_TARGET_NOT_FOUND")
161 .with_help(
162 "The module is not in the analyzed module graph. Run project_info to list \
163 discovered files; both paths must be project-relative and not excluded by \
164 ignore patterns or outside the analyzed roots.",
165 )
166 .with_context("trace_import_path")
167 })?;
168 Ok(TraceImportPathProgrammaticOutput { output })
169 })
170}
171
172pub fn run_trace_error(
184 options: &TraceErrorOptions,
185) -> ProgrammaticResult<TraceErrorProgrammaticOutput> {
186 if options.trace.trim().is_empty() {
187 return Err(ProgrammaticError::new("trace must not be empty", 2)
191 .with_code("FALLOW_INVALID_TRACE_OPTIONS")
192 .with_help(
193 "Paste the stack trace text into `trace`, as your runtime printed it. \
194 trace_error resolves frames against the project graph, so it has nothing \
195 to resolve without them.",
196 )
197 .with_context("trace_error"));
198 }
199 if options.trace.len() as u64 > fallow_engine::trace_error::MAX_STACK_TRACE_BYTES {
200 let limit = fallow_engine::trace_error::MAX_STACK_TRACE_BYTES;
201 return Err(ProgrammaticError::new(
202 format!("stack trace exceeds the {limit}-byte limit"),
203 2,
204 )
205 .with_code("FALLOW_INVALID_TRACE_OPTIONS")
206 .with_help(
207 "A stack trace is a handful of kilobytes. Pass the trace itself rather than a \
208 redirected log file, and cut it to the frames that matter.",
209 )
210 .with_context("trace_error"));
211 }
212 let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
213 resolved.install(|| {
214 let session = load_trace_session(&resolved)?;
215 let output = fallow_engine::trace_error::trace_error_with_session(
216 &session,
217 &options.trace,
218 options.source.clone(),
219 )
220 .map_err(|err| {
221 ProgrammaticError::new(format!("stack-trace resolution failed: {err}"), 2)
222 .with_code("FALLOW_ANALYSIS_FAILED")
223 .with_context("trace_error")
224 })?;
225 Ok(TraceErrorProgrammaticOutput { output })
226 })
227}
228
229pub fn run_trace_dependency(
236 options: &TraceDependencyOptions,
237) -> ProgrammaticResult<TraceDependencyProgrammaticOutput> {
238 validate_non_empty("package_name", &options.package_name)?;
239 let resolved = resolve_programmatic_analysis_context(&options.analysis)?;
240 resolved.install(|| {
241 let session = load_trace_session(&resolved)?;
242 if let Some(query) = &options.usage {
243 return trace_dependency_usage(&session, &options.package_name, query);
244 }
245 let artifacts = trace_artifacts(&session)?;
246 let workspace_roots: Vec<&std::path::Path> = session
247 .workspaces()
248 .iter()
249 .map(|ws| ws.root.as_path())
250 .collect();
251 let output = fallow_engine::trace::build_dependency_trace(
252 &fallow_engine::trace::DependencyTraceInputs {
253 graph: &artifacts.graph,
254 root: session.root(),
255 workspace_roots: &workspace_roots,
256 ignore_patterns: &session.config().ignore_patterns,
257 script_used_packages: &artifacts.script_used_packages,
258 provenance: &artifacts.trace_provenance,
259 results: &artifacts.results,
260 },
261 &options.package_name,
262 );
263 Ok(TraceDependencyProgrammaticOutput { output })
264 })
265}
266
267fn trace_dependency_usage(
269 session: &AnalysisSession,
270 package_name: &str,
271 query: &DependencyUsageQuery,
272) -> ProgrammaticResult<TraceDependencyProgrammaticOutput> {
273 let result = fallow_engine::trace::trace_dependency_with_session(session, package_name, query)
274 .map_err(|err| {
275 super::dead_code::map_engine_error(
276 &err,
277 "trace analysis failed",
278 "FALLOW_TRACE_FAILED",
279 "trace",
280 )
281 })?;
282 result
283 .map(|output| TraceDependencyProgrammaticOutput { output })
284 .map_err(|err| {
285 ProgrammaticError::new(err.to_string(), 2)
286 .with_code("FALLOW_TRACE_INVALID_CURSOR")
287 .with_context("cursor")
288 })
289}
290
291pub fn run_trace_clone(
298 options: &TraceCloneOptions,
299) -> ProgrammaticResult<TraceCloneProgrammaticOutput> {
300 validate_trace_clone_target(&options.target)?;
301 let resolved = resolve_programmatic_analysis_context(&options.duplication.analysis)?;
302 resolved.install(|| {
303 resolved.ensure_not_cancelled("config load and file discovery")?;
304 let session = duplication::load_duplication_session(&options.duplication, &resolved)?;
305 validate_workspace_selection(&resolved, session.workspaces())?;
306 resolved.ensure_not_cancelled("duplication detection")?;
307 let dupes_config =
308 duplication::build_dupes_config(&options.duplication, &session.config().duplicates);
309 let cache_dir = (!resolved.no_cache).then_some(session.config().cache_dir.as_path());
310 let report = session
311 .find_duplicates_with_defaults(&dupes_config, cache_dir)
312 .report;
313 resolved.ensure_not_cancelled("the clone trace")?;
316 let (trace, not_found) = match &options.target {
317 TraceCloneTarget::Location { file, line } => (
318 fallow_engine::trace::trace_clone(&report, session.root(), file, *line),
319 format!("no clone found at {file}:{line}"),
320 ),
321 TraceCloneTarget::Fingerprint(fingerprint) => (
322 fallow_engine::trace::trace_clone_by_fingerprint(
323 &report,
324 session.root(),
325 fingerprint,
326 ),
327 format!("no clone group with fingerprint {fingerprint}"),
328 ),
329 };
330 if trace.matched_instance.is_none() {
331 return Err(ProgrammaticError::new(not_found, 2)
332 .with_code("FALLOW_TRACE_TARGET_NOT_FOUND")
333 .with_help(
334 "No clone matched. Run find_dupes to list clone groups and their fingerprints; \
335 a location must fall inside a reported clone instance, and a fingerprint must \
336 be a find_dupes clone_groups[].fingerprint (a dup:<id> value).",
337 )
338 .with_context("trace_clone"));
339 }
340 Ok(TraceCloneProgrammaticOutput { output: trace })
341 })
342}
343
344#[doc(hidden)]
352#[allow(
353 clippy::implicit_hasher,
354 reason = "the engine trace boundary intentionally accepts the workspace-standard FxHashSet"
355)]
356pub fn benchmark_trace_graph_family_compact_json(
357 graph: &fallow_engine::module_graph::RetainedModuleGraph,
358 root: &std::path::Path,
359 script_used_packages: &FxHashSet<String>,
360) -> ProgrammaticResult<(usize, usize, usize, usize, usize)> {
361 let export =
362 fallow_engine::trace::trace_export(graph, root, "src/000-shared.ts", "sharedValue")
363 .ok_or_else(|| benchmark_trace_target_missing("src/000-shared.ts:sharedValue"))?;
364 let export_reference_count = export.direct_references.len();
365 let export_json =
366 crate::serialize_trace_export_programmatic_json(TraceExportProgrammaticOutput {
367 output: TraceExportTargetOutput::Export(export),
368 })?;
369
370 let file = fallow_engine::trace::trace_file(graph, root, "src/000-shared.ts")
371 .ok_or_else(|| benchmark_trace_target_missing("src/000-shared.ts"))?;
372 let file_export_count = file.exports.len();
373 let file_imported_by_count = file.imported_by.len();
374 let file_json = crate::serialize_trace_file_programmatic_json(TraceFileProgrammaticOutput {
375 output: file,
376 })?;
377
378 let dependency = fallow_engine::trace::trace_dependency(
379 graph,
380 root,
381 &[],
382 &fallow_config::IgnorePatternSet::empty(),
383 "trace-package",
384 script_used_packages,
385 );
386 let dependency_import_count = dependency.import_count;
387 let dependency_json =
388 crate::serialize_trace_dependency_programmatic_json(TraceDependencyProgrammaticOutput {
389 output: dependency,
390 })?;
391
392 let rendered_bytes = compact_json_len(&[export_json, file_json, dependency_json])?;
393 Ok((
394 export_reference_count,
395 file_export_count,
396 file_imported_by_count,
397 dependency_import_count,
398 rendered_bytes,
399 ))
400}
401
402#[doc(hidden)]
404#[derive(Debug, PartialEq, Eq)]
405pub struct TraceCloneBenchmarkResult {
406 pub location_file: std::path::PathBuf,
408 pub location_line: usize,
410 pub location_fingerprint: String,
412 pub fingerprint_fingerprint: String,
414 pub location_group_count: usize,
416 pub fingerprint_group_count: usize,
418 pub location_instance_count: usize,
420 pub fingerprint_instance_count: usize,
422 pub rendered_bytes: usize,
424}
425
426#[doc(hidden)]
433pub fn benchmark_trace_clone_compact_json(
434 report: &DuplicationReport,
435 root: &std::path::Path,
436 file: &str,
437 line: usize,
438 fingerprint: &str,
439) -> ProgrammaticResult<TraceCloneBenchmarkResult> {
440 let location_trace = fallow_engine::trace::trace_clone(report, root, file, line);
441 let matched_location = location_trace
442 .matched_instance
443 .as_ref()
444 .ok_or_else(|| benchmark_trace_target_missing(&format!("{file}:{line}")))?;
445 let location_file = matched_location.file.clone();
446 let location_line = matched_location.start_line;
447 let location_fingerprint = location_trace
448 .clone_groups
449 .first()
450 .map(|group| group.fingerprint.clone())
451 .ok_or_else(|| benchmark_trace_target_missing(&format!("{file}:{line}")))?;
452 let location_group_count = location_trace.clone_groups.len();
453 let location_instance_count = location_trace
454 .clone_groups
455 .iter()
456 .map(|group| group.instances.len())
457 .sum();
458 let location_json =
459 crate::serialize_trace_clone_programmatic_json(TraceCloneProgrammaticOutput {
460 output: location_trace,
461 })?;
462
463 let fingerprint_trace =
464 fallow_engine::trace::trace_clone_by_fingerprint(report, root, fingerprint);
465 if fingerprint_trace.matched_instance.is_none() {
466 return Err(benchmark_trace_target_missing(fingerprint));
467 }
468 let fingerprint_fingerprint = fingerprint_trace
469 .clone_groups
470 .first()
471 .map(|group| group.fingerprint.clone())
472 .ok_or_else(|| benchmark_trace_target_missing(fingerprint))?;
473 let fingerprint_group_count = fingerprint_trace.clone_groups.len();
474 let fingerprint_instance_count = fingerprint_trace
475 .clone_groups
476 .iter()
477 .map(|group| group.instances.len())
478 .sum();
479 let fingerprint_json =
480 crate::serialize_trace_clone_programmatic_json(TraceCloneProgrammaticOutput {
481 output: fingerprint_trace,
482 })?;
483
484 let rendered_bytes = compact_json_len(&[location_json, fingerprint_json])?;
485 Ok(TraceCloneBenchmarkResult {
486 location_file,
487 location_line,
488 location_fingerprint,
489 fingerprint_fingerprint,
490 location_group_count,
491 fingerprint_group_count,
492 location_instance_count,
493 fingerprint_instance_count,
494 rendered_bytes,
495 })
496}
497
498fn compact_json_len(values: &[serde_json::Value]) -> ProgrammaticResult<usize> {
499 serde_json::to_vec(values)
500 .map(|json| json.len())
501 .map_err(|err| {
502 ProgrammaticError::new(
503 format!("failed to serialize benchmark trace JSON: {err}"),
504 2,
505 )
506 .with_code("FALLOW_SERIALIZE_BENCHMARK_TRACE")
507 .with_context("benchmark_trace")
508 })
509}
510
511fn benchmark_trace_target_missing(target: &str) -> ProgrammaticError {
512 ProgrammaticError::new(format!("benchmark trace target not found: {target}"), 2)
513 .with_code("FALLOW_BENCHMARK_TRACE_TARGET_NOT_FOUND")
514 .with_context("benchmark_trace")
515}
516
517fn validate_non_empty(field: &str, value: &str) -> ProgrammaticResult<()> {
518 if value.trim().is_empty() {
519 return Err(
520 ProgrammaticError::new(format!("{field} must not be empty"), 2)
521 .with_code("FALLOW_INVALID_TRACE_OPTIONS")
522 .with_context(field.to_string()),
523 );
524 }
525 Ok(())
526}
527
528fn validate_trace_clone_target(target: &TraceCloneTarget) -> ProgrammaticResult<()> {
529 match target {
530 TraceCloneTarget::Location { file, line } => {
531 validate_non_empty("file", file)?;
532 if *line == 0 {
533 return Err(ProgrammaticError::new("line must be greater than 0", 2)
534 .with_code("FALLOW_INVALID_TRACE_OPTIONS")
535 .with_context("trace_clone.line"));
536 }
537 }
538 TraceCloneTarget::Fingerprint(fingerprint) => {
539 validate_non_empty("fingerprint", fingerprint)?;
540 }
541 }
542 Ok(())
543}
544
545fn load_trace_session(
546 resolved: &ProgrammaticAnalysisContext,
547) -> ProgrammaticResult<AnalysisSession> {
548 let session = super::dead_code::load_dead_code_session(
549 &super::dead_code::default_dead_code_options_for_context(resolved),
550 resolved,
551 )?;
552 validate_workspace_selection(resolved, session.workspaces())?;
553 Ok(session)
554}
555
556fn trace_artifacts(session: &AnalysisSession) -> ProgrammaticResult<TraceArtifacts> {
557 let artifacts = session
558 .analyze_dead_code_with_session_artifacts(false, true, None)
559 .map_err(|err| {
560 super::dead_code::map_engine_error(
561 &err,
562 "trace analysis failed",
563 "FALLOW_TRACE_FAILED",
564 "trace",
565 )
566 })?;
567 let graph = artifacts.analysis.graph.ok_or_else(|| {
568 ProgrammaticError::new("trace requires a retained module graph", 2)
569 .with_code("FALLOW_TRACE_GRAPH_UNAVAILABLE")
570 .with_context("trace.graph")
571 })?;
572 Ok(TraceArtifacts {
573 graph,
574 script_used_packages: artifacts.analysis.script_used_packages,
575 trace_provenance: artifacts.analysis.trace_provenance,
576 results: artifacts.analysis.results,
577 })
578}
579
580#[cfg(test)]
581mod benchmark_tests {
582 use std::fmt::Write as _;
583 use std::fs;
584
585 use fallow_engine::duplicates::CloneFingerprintSet;
586
587 use super::*;
588
589 const FIXTURE_SIZE: usize = 4;
590
591 fn write_file(root: &std::path::Path, path: &str, source: impl AsRef<str>) {
592 let path = root.join(path);
593 fs::create_dir_all(path.parent().expect("fixture file has parent"))
594 .expect("fixture directory is created");
595 fs::write(path, source.as_ref()).expect("fixture file is written");
596 }
597
598 #[test]
599 fn graph_family_benchmark_boundary_uses_only_retained_artifacts() {
600 let temp_dir = tempfile::TempDir::new().expect("temporary project is created");
601 let root = temp_dir.path().to_path_buf();
602 write_file(
603 &root,
604 "package.json",
605 r#"{"name":"trace-test","type":"module","main":"src/index.ts"}"#,
606 );
607 write_file(
608 &root,
609 "src/000-shared.ts",
610 "export const sharedValue = 42;\n",
611 );
612
613 let mut index_source = String::new();
614 for index in 0..FIXTURE_SIZE {
615 write_file(
616 &root,
617 &format!("src/consumer{index}.ts"),
618 format!(
619 "import {{ sharedValue }} from './000-shared';\nimport {{ traceHelper }} from 'trace-package';\nexport const value{index} = traceHelper(sharedValue + {index});\n"
620 ),
621 );
622 writeln!(
623 index_source,
624 "import {{ value{index} }} from './consumer{index}';\nconsole.log(value{index});"
625 )
626 .expect("index source is built");
627 }
628 write_file(&root, "src/index.ts", index_source);
629
630 let session = AnalysisSession::load(&root, None).expect("trace session loads");
631 let target = session
632 .files()
633 .iter()
634 .find(|file| file.path.ends_with("src/000-shared.ts"))
635 .expect("trace target is discovered");
636 assert_eq!(
637 target.id.0, 0,
638 "the retained trace target must precede every non-matching importer"
639 );
640 let trace_root = session.root().to_path_buf();
641 let artifacts = session
642 .analyze_dead_code_with_artifacts(false, true)
643 .expect("trace graph analysis succeeds");
644 drop(session);
645 temp_dir.close().expect("temporary project is removed");
646
647 let result = benchmark_trace_graph_family_compact_json(
648 artifacts.graph.as_ref().expect("trace graph is retained"),
649 &trace_root,
650 &artifacts.script_used_packages,
651 )
652 .expect("retained trace graph serializes without project IO");
653 assert_eq!(result.0, FIXTURE_SIZE);
654 assert_eq!(result.1, 1);
655 assert_eq!(result.2, FIXTURE_SIZE);
656 assert_eq!(result.3, FIXTURE_SIZE);
657 assert!(result.4 > 0);
658 }
659
660 #[test]
661 fn clone_benchmark_boundary_uses_only_the_retained_report() {
662 let temp_dir = tempfile::TempDir::new().expect("temporary project is created");
663 let root = temp_dir.path().to_path_buf();
664 write_file(
665 &root,
666 "package.json",
667 r#"{"name":"trace-clone-test","type":"module"}"#,
668 );
669 for index in 0..FIXTURE_SIZE {
670 write_file(
671 &root,
672 &format!("src/clone{index}.ts"),
673 format!(
674 "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"
675 ),
676 );
677 }
678
679 let session = AnalysisSession::load(&root, None).expect("clone session loads");
680 let trace_root = session.root().to_path_buf();
681 let mut config = session.config().duplicates.clone();
682 config.min_tokens = 35;
683 config.min_lines = 5;
684 config.min_occurrences = FIXTURE_SIZE;
685 let report = session.find_duplicates_with_defaults(&config, None).report;
686 let group = report
687 .clone_groups
688 .iter()
689 .max_by_key(|group| group.instances.len())
690 .expect("clone group exists");
691 let target = group.instances.last().expect("clone instance exists");
692 let target_file = target
693 .file
694 .strip_prefix(&trace_root)
695 .expect("clone path is project-relative")
696 .to_string_lossy()
697 .replace('\\', "/");
698 let target_line = target.start_line;
699 let expected_fingerprint =
700 CloneFingerprintSet::from_groups(&report.clone_groups).fingerprint_for_group(group);
701 drop(session);
702 temp_dir.close().expect("temporary project is removed");
703
704 let result = benchmark_trace_clone_compact_json(
705 &report,
706 &trace_root,
707 &target_file,
708 target_line,
709 &expected_fingerprint,
710 )
711 .expect("retained clone report serializes without project IO");
712 assert_eq!(result.location_file, std::path::PathBuf::from(&target_file));
713 assert_eq!(result.location_line, target_line);
714 assert_eq!(result.location_fingerprint, expected_fingerprint);
715 assert_eq!(result.fingerprint_fingerprint, expected_fingerprint);
716 assert_eq!(result.location_group_count, 1);
717 assert_eq!(result.fingerprint_group_count, 1);
718 assert_eq!(result.location_instance_count, FIXTURE_SIZE);
719 assert_eq!(result.fingerprint_instance_count, FIXTURE_SIZE);
720 assert!(result.rendered_bytes > 0);
721 }
722}