Skip to main content

supercov_engine/
ruby_run.rs

1//! Public Ruby coverage run lifecycle.
2//!
3//! The project runs in place with its own interpreter, bundle and test
4//! command. Supercov prepares the complete obligation manifest and probe plan
5//! from source, materialises its stdlib-only runtime under `.supercov/`, loads
6//! it through `RUBYOPT`, supervises the user's command unchanged, and
7//! publishes the joined evidence.
8
9use std::{
10    ffi::OsString,
11    fs,
12    io::Write,
13    path::{Path, PathBuf},
14    time::Instant,
15};
16
17use serde::{Deserialize, Serialize};
18
19use crate::workspace::canonicalize_simplified;
20use crate::{
21    evidence_archive::write_archive,
22    frontend_protocol::validate_frontend_report_request,
23    integrity::{FrontendIntegrityInputs, create_explicit_run_integrity},
24    lifecycle::{
25        ProjectLock, finalize_published_run, note_kept_evidence, publish_run,
26        recover_abandoned_runs, remove_stored_tree_deferred,
27    },
28    orchestration::{ExecutionPhase, ExecutionPlan, PhaseKind, execute_plan},
29    process_supervision::{CommandSpec, SupervisionOptions},
30    ruby_evidence::{RubyFrontendRun, build_ruby_frontend_run},
31    ruby_project::{PreparedRubyProject, prepare_ruby_project, ruby_integrity_inputs},
32    run_store::{RawEvidenceMetadata, RunMetadata, RunTimings},
33};
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "camelCase", deny_unknown_fields)]
37pub struct DirectRubyRunRequest {
38    pub root: PathBuf,
39    pub command: Vec<String>,
40    pub run_id: String,
41    pub started_at: String,
42}
43
44#[derive(Debug, Clone, PartialEq)]
45pub struct DirectRubyRunResult {
46    pub run_id: String,
47    pub run_directory: PathBuf,
48    pub exit_code: i32,
49    pub tests: usize,
50    pub source_files: usize,
51    pub interpreters: usize,
52    pub ruby_versions: Vec<String>,
53    pub recovered_runs: Vec<String>,
54    pub metadata: RunMetadata,
55}
56
57fn elapsed_ms(started: Instant) -> f64 {
58    (started.elapsed().as_secs_f64() * 10_000.0).round() / 10.0
59}
60
61fn embedded_runtime_files() -> [(&'static str, &'static [u8]); 5] {
62    [
63        (
64            "supercov_runtime.rb",
65            include_bytes!("../runtime-assets/ruby/supercov_runtime.rb"),
66        ),
67        (
68            "supercov_rspec.rb",
69            include_bytes!("../runtime-assets/ruby/supercov_rspec.rb"),
70        ),
71        (
72            "supercov_minitest.rb",
73            include_bytes!("../runtime-assets/ruby/supercov_minitest.rb"),
74        ),
75        (
76            "supercov_testunit.rb",
77            include_bytes!("../runtime-assets/ruby/supercov_testunit.rb"),
78        ),
79        (
80            "supercov_cucumber.rb",
81            include_bytes!("../runtime-assets/ruby/supercov_cucumber.rb"),
82        ),
83    ]
84}
85
86fn write_runtime(directory: &Path) -> Result<(), String> {
87    fs::create_dir_all(directory).map_err(|error| format!("{}: {error}", directory.display()))?;
88    for (name, contents) in embedded_runtime_files() {
89        let path = directory.join(name);
90        fs::write(&path, contents).map_err(|error| format!("{}: {error}", path.display()))?;
91    }
92    Ok(())
93}
94
95fn copy_tree(source: &Path, destination: &Path) -> Result<(), String> {
96    for entry in fs::read_dir(source).map_err(|error| format!("{}: {error}", source.display()))? {
97        let entry = entry.map_err(|error| error.to_string())?;
98        let target = destination.join(entry.file_name());
99        if entry
100            .file_type()
101            .map_err(|error| error.to_string())?
102            .is_dir()
103        {
104            fs::create_dir_all(&target).map_err(|error| error.to_string())?;
105            copy_tree(&entry.path(), &target)?;
106        } else {
107            fs::copy(entry.path(), &target).map_err(|error| error.to_string())?;
108        }
109    }
110    Ok(())
111}
112
113fn environment(
114    root: &Path,
115    run_id: &str,
116    runtime_directory: &Path,
117    plan_path: &Path,
118    evidence_directory: &Path,
119) -> Vec<(OsString, OsString)> {
120    let mut variables = std::env::vars_os().collect::<Vec<_>>();
121    let mut take = |key: &str| {
122        let position = variables.iter().position(|(name, _)| name == key);
123        position.map(|index| variables.remove(index).1)
124    };
125    // `-r` with an absolute path loads the runtime before the main script in
126    // every Ruby the command starts, including bundler and forked workers.
127    let mut rubyopt = OsString::from("-r");
128    rubyopt.push(runtime_directory.join("supercov_runtime.rb"));
129    if let Some(existing) = take("RUBYOPT").filter(|existing| !existing.is_empty()) {
130        rubyopt.push(" ");
131        rubyopt.push(existing);
132    }
133    for key in [
134        "SUPERCOV_RUBY_PLAN",
135        "SUPERCOV_RUBY_EVIDENCE_DIR",
136        "SUPERCOV_RUBY_SOURCE_ROOT",
137        "SUPERCOV_RUN_ID",
138        "SUPERCOV_PROJECT_ROOT",
139        "SUPERCOV_CONTEXT",
140        "SUPERCOV_RUBY_WORKER",
141    ] {
142        take(key);
143    }
144    variables.extend([
145        ("RUBYOPT".into(), rubyopt),
146        (
147            "SUPERCOV_RUBY_PLAN".into(),
148            plan_path.as_os_str().to_owned(),
149        ),
150        (
151            "SUPERCOV_RUBY_EVIDENCE_DIR".into(),
152            evidence_directory.as_os_str().to_owned(),
153        ),
154        ("SUPERCOV_RUN_ID".into(), run_id.into()),
155        ("SUPERCOV_PROJECT_ROOT".into(), root.as_os_str().to_owned()),
156        (
157            BOOTSNAP_COMPILE_CACHE.0.into(),
158            BOOTSNAP_COMPILE_CACHE.1.into(),
159        ),
160    ]);
161    variables
162}
163
164/// Bootsnap, which every Rails app loads from `config/boot.rb`, compiles each
165/// file once and loads the cached instructions after, past the loader that
166/// puts Supercov's probes in: lines still came from Ruby's own Coverage, and
167/// branches and conditions from nowhere -- 0.08% of a Rails server's branches
168/// against 1.9% with the cache off. Supercov compiles every measured file
169/// itself, so the cache saves nothing here.
170const BOOTSNAP_COMPILE_CACHE: (&str, &str) = ("DISABLE_BOOTSNAP_COMPILE_CACHE", "1");
171
172/// The fingerprint a later query compares against the stored run: the same
173/// discovery and inputs the run used, without preparing a plan.
174pub fn current_ruby_integrity(
175    root: &Path,
176    command: &[String],
177    source_roots: Option<&[String]>,
178) -> Result<crate::run_store::RunIntegrity, String> {
179    let root = canonicalize_simplified(root).map_err(|error| error.to_string())?;
180    let mut files = crate::ruby_project::discover_ruby_files(&root)?;
181    // The roots the run was measured under, not this shell's: the run
182    // digested the files they kept, and nothing else would match it.
183    match source_roots.filter(|roots| !roots.is_empty()) {
184        Some(configured) => {
185            crate::source_discovery::ExplicitSourceRoots::resolve(&root, configured)
186                .map_err(|error| error.to_string())?
187                .narrow(&mut files.sources, &mut files.excluded, String::as_str);
188        }
189        None => crate::source_discovery::leave_out_what_lies_beside(
190            &mut files.sources,
191            &mut files.excluded,
192        ),
193    }
194    let mut inputs = ruby_integrity_inputs(&files, command);
195    crate::source_discovery::fold_roots_into_configuration(
196        &mut inputs.execution_configuration,
197        source_roots,
198    );
199    create_explicit_run_integrity(&root, &inputs, &FrontendIntegrityInputs::embedded_ruby())
200        .map_err(|error| error.to_string())
201}
202
203pub fn run_direct_ruby(
204    request: &DirectRubyRunRequest,
205    diagnostics: &mut dyn Write,
206) -> Result<DirectRubyRunResult, String> {
207    if request.command.is_empty() {
208        return Err("test command must not be empty".into());
209    }
210    let total_started = Instant::now();
211    let initialization_started = Instant::now();
212    let root = canonicalize_simplified(&request.root)
213        .map_err(|error| format!("{}: {error}", request.root.display()))?;
214    let mut lock = ProjectLock::acquire(&root, &request.run_id, &request.started_at)
215        .map_err(|error| error.to_string())?;
216    let initialization_ms = elapsed_ms(initialization_started);
217    let work_directory = root.join(".supercov/work").join(&request.run_id);
218    // Whether the tests were measured. A run that failed before that has no
219    // evidence worth keeping; one that failed after has evidence that cost the
220    // suite its wall clock.
221    let measured = std::cell::Cell::new(false);
222    let result = (|| {
223        let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
224            .map_err(|error| error.to_string())?;
225        if !recovered_runs.is_empty() {
226            writeln!(
227                diagnostics,
228                "[supercov] recovered abandoned run(s): {}",
229                recovered_runs.join(", ")
230            )
231            .map_err(|error| error.to_string())?;
232        }
233
234        let adapter_started = Instant::now();
235        let ambient = std::env::vars().collect::<std::collections::BTreeMap<_, _>>();
236        let roots = crate::source_discovery::ExplicitSourceRoots::from_environment(&root, &ambient)
237            .map_err(|error| error.to_string())?;
238        let project: PreparedRubyProject = prepare_ruby_project(&root, roots.as_ref())?;
239        let source_roots = crate::source_discovery::configured_source_roots(
240            &std::env::vars().collect::<std::collections::BTreeMap<_, _>>(),
241        );
242        let mut integrity_inputs = ruby_integrity_inputs(&project.files, &request.command);
243        crate::source_discovery::fold_roots_into_configuration(
244            &mut integrity_inputs.execution_configuration,
245            source_roots.as_deref(),
246        );
247        let integrity = create_explicit_run_integrity(
248            &root,
249            &integrity_inputs,
250            &FrontendIntegrityInputs::embedded_ruby(),
251        )
252        .map_err(|error| error.to_string())?;
253        let source_inputs = crate::source_capture::capture(
254            &root,
255            "ruby",
256            ruby_integrity_inputs(&project.files, &request.command).assertion_paths(),
257        )?;
258        let ruby_directory = work_directory.join("ruby");
259        let runtime_directory = ruby_directory.join("runtime");
260        let evidence_directory = ruby_directory.join("evidence");
261        // The plan is a Ruby literal rather than JSON: the runtime is
262        // required through `RUBYOPT` before Bundler runs, and loading the
263        // `json` default gem there would clash with the version an
264        // application's Gemfile pins.
265        let plan_path = ruby_directory.join("plan.rb");
266        write_runtime(&runtime_directory)?;
267        fs::create_dir_all(&evidence_directory).map_err(|error| error.to_string())?;
268        fs::write(
269            &plan_path,
270            ruby_literal(&serde_json::to_value(&project.plan).map_err(|error| error.to_string())?)
271                .into_bytes(),
272        )
273        .map_err(|error| format!("{}: {error}", plan_path.display()))?;
274        writeln!(
275            diagnostics,
276            "[supercov] detected Ruby; measuring {} source file(s) in place through Ruby's Coverage module and load-time probes",
277            project.plan.files.len()
278        )
279        .map_err(|error| error.to_string())?;
280        for (file, reason) in &project.unparseable {
281            writeln!(
282                diagnostics,
283                "[supercov] could not parse {file}: {reason}; it carries no obligations"
284            )
285            .map_err(|error| error.to_string())?;
286        }
287        let adapter_setup_ms = elapsed_ms(adapter_started);
288
289        let test_started = Instant::now();
290        let plan = ExecutionPlan {
291            preparation: Vec::new(),
292            test: ExecutionPhase {
293                name: "test".into(),
294                kind: PhaseKind::Test,
295                command: CommandSpec {
296                    program: request.command[0].clone().into(),
297                    arguments: request.command[1..].iter().map(OsString::from).collect(),
298                    cwd: root.clone(),
299                    environment: Some(environment(
300                        &root,
301                        &request.run_id,
302                        &runtime_directory,
303                        &plan_path,
304                        &evidence_directory,
305                    )),
306                    captured_output: None,
307                    console: None,
308                },
309            },
310        };
311        let options = SupervisionOptions::from_environment().map_err(|error| error.to_string())?;
312        let execution = execute_plan(&plan, options, diagnostics, |_, _| Ok(()))
313            .map_err(|error| error.to_string())?;
314        let test_command_ms = elapsed_ms(test_started);
315        if let Some(signal) = execution.interrupted_signal {
316            return Err(format!(
317                "the test command was interrupted by {signal:?}; no run was published"
318            ));
319        }
320
321        if std::env::var("SUPERCOV_KEEP_WORK").is_ok_and(|value| !value.is_empty()) {
322            // Kept before the join so a rejected evidence file stays inspectable.
323            let debug_directory = root.join(".supercov/ruby-debug").join(&request.run_id);
324            fs::create_dir_all(&debug_directory).map_err(|error| error.to_string())?;
325            copy_tree(&ruby_directory, &debug_directory)?;
326        }
327        let publication_started = Instant::now();
328        measured.set(true);
329        let run: RubyFrontendRun = match build_ruby_frontend_run(
330            &project.manifest,
331            &evidence_directory,
332            &request.run_id,
333            &request.started_at,
334            execution.exit_code,
335            &root,
336        ) {
337            // No test reported an outcome and the command itself failed: it
338            // never ran its tests (`No module named pytest`, an error while
339            // collecting). That is the command's failure, said in its own
340            // output, not a Supercov bug to report with evidence.
341            Err(
342                crate::ruby_evidence::RubyEvidenceError::NoTests
343                | crate::ruby_evidence::RubyEvidenceError::NoInterpreter,
344            ) if execution.exit_code != 0 => {
345                measured.set(false);
346                return Err(format!(
347                    "the test command exited {} before any test reported an outcome; its own output above says why, and no run was published",
348                    execution.exit_code
349                ));
350            }
351            // The interpreter or the runner the command used is not one
352            // Supercov measures: the message says what to change, and the
353            // evidence has nothing in it to diagnose.
354            Err(
355                error @ (crate::ruby_evidence::RubyEvidenceError::UnsupportedRuby(_)
356                | crate::ruby_evidence::RubyEvidenceError::NoTests
357                | crate::ruby_evidence::RubyEvidenceError::NoInterpreter),
358            ) => {
359                measured.set(false);
360                return Err(error.to_string());
361            }
362            result => result.map_err(|error| error.to_string())?,
363        };
364        validate_frontend_report_request(&run.declaration, &run.request)
365            .map_err(|error| error.to_string())?;
366        let archive_path = work_directory.join("evidence.raw.gz");
367        let entries = run.archive_entries().map_err(|error| error.to_string())?;
368        let entries = crate::source_capture::append(entries, &source_inputs)?;
369        let raw = write_archive(entries, &archive_path).map_err(|error| error.to_string())?;
370        remove_stored_tree_deferred(&root, &ruby_directory).map_err(|error| error.to_string())?;
371        let evidence_publication_ms = elapsed_ms(publication_started);
372        let timings = RunTimings {
373            initialization_ms,
374            workspace_preparation_ms: 0.0,
375            adapter_setup_ms,
376            instrumented_build_ms: 0.0,
377            test_command_ms,
378            evidence_publication_ms,
379        };
380        let metadata = RunMetadata {
381            id: request.run_id.clone(),
382            started_at: request.started_at.clone(),
383            duration_ms: elapsed_ms(total_started),
384            command: request.command.clone(),
385            test_exit_code: Some(execution.exit_code),
386            integrity,
387            raw_evidence: RawEvidenceMetadata {
388                schema_version: raw.schema_version,
389                format: raw.format.into(),
390                file: raw.file.into(),
391                files: raw.files,
392                uncompressed_bytes: raw.uncompressed_bytes,
393                compressed_bytes: raw.compressed_bytes,
394            },
395            isolated_build: None,
396            instrumented_build_cache: None,
397            timings: Some(timings),
398            merged: None,
399            parents: None,
400            source_roots: source_roots.clone(),
401            copy: None,
402            merged_across: None,
403            recorded_by: None,
404            companions: None,
405        };
406        let run_directory =
407            publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
408        finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
409        Ok(DirectRubyRunResult {
410            run_id: request.run_id.clone(),
411            run_directory,
412            exit_code: execution.exit_code,
413            tests: run.tests,
414            source_files: project.plan.files.len(),
415            interpreters: run.interpreters,
416            ruby_versions: run.ruby_versions,
417            recovered_runs,
418            metadata,
419        })
420    })();
421    let result = result.map_err(|error| {
422        if !measured.get() {
423            return error;
424        }
425        note_kept_evidence(
426            &root,
427            &work_directory.join("ruby/evidence"),
428            &request.run_id,
429            error,
430        )
431    });
432    if result.is_err() {
433        let _ = remove_stored_tree_deferred(&root, &work_directory);
434    }
435    let release = lock.release().map_err(|error| error.to_string());
436    match (result, release) {
437        (Ok(result), Ok(())) => Ok(result),
438        (Err(error), _) => Err(error),
439        (Ok(_), Err(error)) => Err(error),
440    }
441}
442
443/// Ruby measured inside a browser suite's run: the server its tests drive,
444/// which Playwright's `webServer` or a fixture starts. Prepared whenever the
445/// project has Ruby source; a run whose suite starts no measured Ruby
446/// publishes nothing of it.
447pub struct RubyServer {
448    project: PreparedRubyProject,
449    integrity: crate::run_store::RunIntegrity,
450    source_inputs: crate::source_manifest::Inputs,
451    source_roots: Option<Vec<String>>,
452    directory: PathBuf,
453}
454
455impl RubyServer {
456    /// The plan and runtime for the project's Ruby source, or None when it
457    /// has none. `workspace` is where the suite runs: the probes find the
458    /// server's files there.
459    pub fn prepare(
460        root: &Path,
461        workspace: &Path,
462        run_id: &str,
463        command: &[String],
464    ) -> Result<Option<Self>, String> {
465        let ambient = std::env::vars().collect::<std::collections::BTreeMap<_, _>>();
466        // Roots named for the JavaScript source may hold no Ruby at all.
467        let Ok(roots) =
468            crate::source_discovery::ExplicitSourceRoots::from_environment(root, &ambient)
469        else {
470            return Ok(None);
471        };
472        let project = prepare_ruby_project(root, roots.as_ref())?;
473        if project.plan.files.is_empty() {
474            return Ok(None);
475        }
476        let source_roots = crate::source_discovery::configured_source_roots(&ambient);
477        let mut integrity_inputs = ruby_integrity_inputs(&project.files, command);
478        crate::source_discovery::fold_roots_into_configuration(
479            &mut integrity_inputs.execution_configuration,
480            source_roots.as_deref(),
481        );
482        let integrity = create_explicit_run_integrity(
483            root,
484            &integrity_inputs,
485            &FrontendIntegrityInputs::embedded_ruby(),
486        )
487        .map_err(|error| error.to_string())?;
488        let source_inputs = crate::source_capture::capture(
489            root,
490            "ruby",
491            ruby_integrity_inputs(&project.files, command).assertion_paths(),
492        )?;
493        let directory = root.join(".supercov/work").join(run_id).join("ruby");
494        write_runtime(&directory.join("runtime"))?;
495        fs::create_dir_all(directory.join("evidence")).map_err(|error| error.to_string())?;
496        let mut plan = serde_json::to_value(&project.plan).map_err(|error| error.to_string())?;
497        plan["root"] = workspace.display().to_string().into();
498        let plan_path = directory.join("plan.rb");
499        fs::write(&plan_path, ruby_literal(&plan).into_bytes())
500            .map_err(|error| format!("{}: {error}", plan_path.display()))?;
501        Ok(Some(Self {
502            project,
503            integrity,
504            source_inputs,
505            source_roots,
506            directory,
507        }))
508    }
509
510    pub fn source_files(&self) -> usize {
511        self.project.plan.files.len()
512    }
513
514    /// What the suite's environment needs for a Ruby process it starts to be
515    /// measured; the run id and project root are the browser run's own.
516    pub fn environment(&self) -> std::collections::BTreeMap<String, String> {
517        let mut rubyopt = format!(
518            "-r{}",
519            self.directory.join("runtime/supercov_runtime.rb").display()
520        );
521        if let Some(existing) = std::env::var("RUBYOPT")
522            .ok()
523            .filter(|value| !value.is_empty())
524        {
525            rubyopt.push(' ');
526            rubyopt.push_str(&existing);
527        }
528        std::collections::BTreeMap::from([
529            ("RUBYOPT".into(), rubyopt),
530            (
531                "SUPERCOV_RUBY_PLAN".into(),
532                self.directory.join("plan.rb").display().to_string(),
533            ),
534            (
535                "SUPERCOV_RUBY_EVIDENCE_DIR".into(),
536                self.directory.join("evidence").display().to_string(),
537            ),
538            (
539                "SUPERCOV_RUBY_SOURCE_ROOT".into(),
540                self.project.root.display().to_string(),
541            ),
542            (
543                BOOTSNAP_COMPILE_CACHE.0.into(),
544                BOOTSNAP_COMPILE_CACHE.1.into(),
545            ),
546        ])
547    }
548
549    /// Publish what the server ran as a Ruby run of its own, credited to the
550    /// browser tests that sent each request. None when no measured Ruby
551    /// process ran, or none answered a test's request.
552    pub fn publish(
553        self,
554        root: &Path,
555        evidence_run_id: &str,
556        metadata: RunMetadata,
557        served: &[crate::served_tests::ServedTest],
558        diagnostics: &mut dyn Write,
559    ) -> Result<Option<(PathBuf, RunMetadata)>, String> {
560        let evidence_directory = self.directory.join("evidence");
561        let result = (|| {
562            if served.is_empty() {
563                return Ok(None);
564            }
565            let mut run = match crate::ruby_evidence::build_ruby_server_run(
566                &self.project.manifest,
567                &evidence_directory,
568                evidence_run_id,
569                &metadata.started_at,
570                metadata.test_exit_code.unwrap_or_default(),
571                &self.project.root,
572                served,
573            ) {
574                Err(
575                    crate::ruby_evidence::RubyEvidenceError::NoInterpreter
576                    | crate::ruby_evidence::RubyEvidenceError::NoTests,
577                ) => return Ok(None),
578                result => result.map_err(|error| error.to_string())?,
579            };
580            let answered = run.request.raw_results.iter().any(|raw| {
581                raw.role == "test" && raw.runtime.iter().any(|snapshot| !snapshot.hits.is_empty())
582            });
583            if !answered {
584                writeln!(
585                    diagnostics,
586                    "[supercov] a Ruby process ran, and no request a test sent reached measured Ruby code; no Ruby run was published"
587                )
588                .map_err(|error| error.to_string())?;
589                return Ok(None);
590            }
591            validate_frontend_report_request(&run.declaration, &run.request)
592                .map_err(|error| error.to_string())?;
593            // The evidence was recorded under the browser run's id; a run
594            // published beside it has its own.
595            run.request.run_id = metadata.id.clone();
596            for scope in run
597                .request
598                .raw_results
599                .iter_mut()
600                .filter_map(|raw| raw.scope.as_mut())
601            {
602                scope.run_id = metadata.id.clone();
603            }
604            let archive_path = root
605                .join(".supercov/work")
606                .join(&metadata.id)
607                .join("ruby-evidence.raw.gz");
608            if let Some(parent) = archive_path.parent() {
609                fs::create_dir_all(parent).map_err(|error| error.to_string())?;
610            }
611            let entries = run.archive_entries().map_err(|error| error.to_string())?;
612            let entries = crate::source_capture::append(entries, &self.source_inputs)?;
613            let raw = write_archive(entries, &archive_path).map_err(|error| error.to_string())?;
614            let tests = run.tests;
615            drop(run);
616            let metadata = RunMetadata {
617                integrity: self.integrity.clone(),
618                raw_evidence: RawEvidenceMetadata {
619                    schema_version: raw.schema_version,
620                    format: raw.format.into(),
621                    file: raw.file.into(),
622                    files: raw.files,
623                    uncompressed_bytes: raw.uncompressed_bytes,
624                    compressed_bytes: raw.compressed_bytes,
625                },
626                source_roots: self.source_roots.clone(),
627                ..metadata
628            };
629            let run_directory =
630                publish_run(root, &metadata, &archive_path).map_err(|error| error.to_string())?;
631            writeln!(
632                diagnostics,
633                "[supercov] Ruby server coverage: {tests} test(s) across {} source file(s)",
634                self.project.plan.files.len()
635            )
636            .map_err(|error| error.to_string())?;
637            Ok(Some((run_directory, metadata)))
638        })();
639        let result = result.map_err(|error| {
640            note_kept_evidence(
641                root,
642                &evidence_directory,
643                &format!("{evidence_run_id}-ruby"),
644                error,
645            )
646        });
647        let _ = remove_stored_tree_deferred(root, &self.directory);
648        result
649    }
650}
651
652/// Render a JSON value as a Ruby literal: `nil`, booleans and numbers as
653/// themselves, strings double-quoted with JSON escapes (which Ruby shares)
654/// plus `#` escaped so nothing interpolates, arrays as `[..]` and objects as
655/// `{"key" => value, ..}` so keys stay strings rather than becoming symbols.
656pub(crate) fn ruby_literal(value: &serde_json::Value) -> String {
657    fn write(value: &serde_json::Value, out: &mut String) {
658        match value {
659            serde_json::Value::Null => out.push_str("nil"),
660            serde_json::Value::Bool(flag) => out.push_str(if *flag { "true" } else { "false" }),
661            serde_json::Value::Number(number) => out.push_str(&number.to_string()),
662            serde_json::Value::String(text) => write_string(text, out),
663            serde_json::Value::Array(items) => {
664                out.push('[');
665                for (index, item) in items.iter().enumerate() {
666                    if index > 0 {
667                        out.push(',');
668                    }
669                    write(item, out);
670                }
671                out.push(']');
672            }
673            serde_json::Value::Object(entries) => {
674                out.push('{');
675                for (index, (key, item)) in entries.iter().enumerate() {
676                    if index > 0 {
677                        out.push(',');
678                    }
679                    write_string(key, out);
680                    out.push_str("=>");
681                    write(item, out);
682                }
683                out.push('}');
684            }
685        }
686    }
687
688    fn write_string(text: &str, out: &mut String) {
689        let json = serde_json::to_string(text).unwrap_or_else(|_| "\"\"".into());
690        out.push_str(&json.replace('#', "\\#"));
691    }
692
693    let mut out = String::new();
694    write(value, &mut out);
695    out.push('\n');
696    out
697}
698
699#[cfg(test)]
700mod literal_tests {
701    use super::ruby_literal;
702
703    #[test]
704    fn ruby_literal_keeps_strings_inert_and_keys_as_strings() {
705        let value = serde_json::json!({
706            "a": [1, 2.5, -3, true, false, null],
707            "text": "quote \" backslash \\ interpolation #{x} tab \t unicode \u{e9}",
708            "nested": {"k": []}
709        });
710        let literal = ruby_literal(&value);
711        assert_eq!(
712            literal,
713            "{\"a\"=>[1,2.5,-3,true,false,nil],\"text\"=>\"quote \\\" backslash \\\\ interpolation \\#{x} tab \\t unicode \u{e9}\",\"nested\"=>{\"k\"=>[]}}\n"
714        );
715    }
716}