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    variables
158}
159
160/// The fingerprint a later query compares against the stored run: the same
161/// discovery and inputs the run used, without preparing a plan.
162pub fn current_ruby_integrity(
163    root: &Path,
164    command: &[String],
165    source_roots: Option<&[String]>,
166) -> Result<crate::run_store::RunIntegrity, String> {
167    let root = canonicalize_simplified(root).map_err(|error| error.to_string())?;
168    let mut files = crate::ruby_project::discover_ruby_files(&root)?;
169    // The roots the run was measured under, not this shell's: the run
170    // digested the files they kept, and nothing else would match it.
171    match source_roots.filter(|roots| !roots.is_empty()) {
172        Some(configured) => {
173            crate::source_discovery::ExplicitSourceRoots::resolve(&root, configured)
174                .map_err(|error| error.to_string())?
175                .narrow(&mut files.sources, &mut files.excluded, String::as_str);
176        }
177        None => crate::source_discovery::leave_out_what_lies_beside(
178            &mut files.sources,
179            &mut files.excluded,
180        ),
181    }
182    let mut inputs = ruby_integrity_inputs(&files, command);
183    crate::source_discovery::fold_roots_into_configuration(
184        &mut inputs.execution_configuration,
185        source_roots,
186    );
187    create_explicit_run_integrity(&root, &inputs, &FrontendIntegrityInputs::embedded_ruby())
188        .map_err(|error| error.to_string())
189}
190
191pub fn run_direct_ruby(
192    request: &DirectRubyRunRequest,
193    diagnostics: &mut dyn Write,
194) -> Result<DirectRubyRunResult, String> {
195    if request.command.is_empty() {
196        return Err("test command must not be empty".into());
197    }
198    let total_started = Instant::now();
199    let initialization_started = Instant::now();
200    let root = canonicalize_simplified(&request.root)
201        .map_err(|error| format!("{}: {error}", request.root.display()))?;
202    let mut lock = ProjectLock::acquire(&root, &request.run_id, &request.started_at)
203        .map_err(|error| error.to_string())?;
204    let initialization_ms = elapsed_ms(initialization_started);
205    let work_directory = root.join(".supercov/work").join(&request.run_id);
206    // Whether the tests were measured. A run that failed before that has no
207    // evidence worth keeping; one that failed after has evidence that cost the
208    // suite its wall clock.
209    let measured = std::cell::Cell::new(false);
210    let result = (|| {
211        let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
212            .map_err(|error| error.to_string())?;
213        if !recovered_runs.is_empty() {
214            writeln!(
215                diagnostics,
216                "[supercov] recovered abandoned run(s): {}",
217                recovered_runs.join(", ")
218            )
219            .map_err(|error| error.to_string())?;
220        }
221
222        let adapter_started = Instant::now();
223        let ambient = std::env::vars().collect::<std::collections::BTreeMap<_, _>>();
224        let roots = crate::source_discovery::ExplicitSourceRoots::from_environment(&root, &ambient)
225            .map_err(|error| error.to_string())?;
226        let project: PreparedRubyProject = prepare_ruby_project(&root, roots.as_ref())?;
227        let source_roots = crate::source_discovery::configured_source_roots(
228            &std::env::vars().collect::<std::collections::BTreeMap<_, _>>(),
229        );
230        let mut integrity_inputs = ruby_integrity_inputs(&project.files, &request.command);
231        crate::source_discovery::fold_roots_into_configuration(
232            &mut integrity_inputs.execution_configuration,
233            source_roots.as_deref(),
234        );
235        let integrity = create_explicit_run_integrity(
236            &root,
237            &integrity_inputs,
238            &FrontendIntegrityInputs::embedded_ruby(),
239        )
240        .map_err(|error| error.to_string())?;
241        let source_inputs = crate::source_capture::capture(
242            &root,
243            "ruby",
244            ruby_integrity_inputs(&project.files, &request.command).assertion_paths(),
245        )?;
246        let ruby_directory = work_directory.join("ruby");
247        let runtime_directory = ruby_directory.join("runtime");
248        let evidence_directory = ruby_directory.join("evidence");
249        // The plan is a Ruby literal rather than JSON: the runtime is
250        // required through `RUBYOPT` before Bundler runs, and loading the
251        // `json` default gem there would clash with the version an
252        // application's Gemfile pins.
253        let plan_path = ruby_directory.join("plan.rb");
254        write_runtime(&runtime_directory)?;
255        fs::create_dir_all(&evidence_directory).map_err(|error| error.to_string())?;
256        fs::write(
257            &plan_path,
258            ruby_literal(&serde_json::to_value(&project.plan).map_err(|error| error.to_string())?)
259                .into_bytes(),
260        )
261        .map_err(|error| format!("{}: {error}", plan_path.display()))?;
262        writeln!(
263            diagnostics,
264            "[supercov] detected Ruby; measuring {} source file(s) in place through Ruby's Coverage module and load-time probes",
265            project.plan.files.len()
266        )
267        .map_err(|error| error.to_string())?;
268        for (file, reason) in &project.unparseable {
269            writeln!(
270                diagnostics,
271                "[supercov] could not parse {file}: {reason}; it carries no obligations"
272            )
273            .map_err(|error| error.to_string())?;
274        }
275        let adapter_setup_ms = elapsed_ms(adapter_started);
276
277        let test_started = Instant::now();
278        let plan = ExecutionPlan {
279            preparation: Vec::new(),
280            test: ExecutionPhase {
281                name: "test".into(),
282                kind: PhaseKind::Test,
283                command: CommandSpec {
284                    program: request.command[0].clone().into(),
285                    arguments: request.command[1..].iter().map(OsString::from).collect(),
286                    cwd: root.clone(),
287                    environment: Some(environment(
288                        &root,
289                        &request.run_id,
290                        &runtime_directory,
291                        &plan_path,
292                        &evidence_directory,
293                    )),
294                    captured_output: None,
295                    console: None,
296                },
297            },
298        };
299        let options = SupervisionOptions::from_environment().map_err(|error| error.to_string())?;
300        let execution = execute_plan(&plan, options, diagnostics, |_, _| Ok(()))
301            .map_err(|error| error.to_string())?;
302        let test_command_ms = elapsed_ms(test_started);
303        if let Some(signal) = execution.interrupted_signal {
304            return Err(format!(
305                "the test command was interrupted by {signal:?}; no run was published"
306            ));
307        }
308
309        if std::env::var("SUPERCOV_KEEP_WORK").is_ok_and(|value| !value.is_empty()) {
310            // Kept before the join so a rejected evidence file stays inspectable.
311            let debug_directory = root.join(".supercov/ruby-debug").join(&request.run_id);
312            fs::create_dir_all(&debug_directory).map_err(|error| error.to_string())?;
313            copy_tree(&ruby_directory, &debug_directory)?;
314        }
315        let publication_started = Instant::now();
316        measured.set(true);
317        let run: RubyFrontendRun = match build_ruby_frontend_run(
318            &project.manifest,
319            &evidence_directory,
320            &request.run_id,
321            &request.started_at,
322            execution.exit_code,
323            &root,
324        ) {
325            // No test reported an outcome and the command itself failed: it
326            // never ran its tests (`No module named pytest`, an error while
327            // collecting). That is the command's failure, said in its own
328            // output, not a Supercov bug to report with evidence.
329            Err(
330                crate::ruby_evidence::RubyEvidenceError::NoTests
331                | crate::ruby_evidence::RubyEvidenceError::NoInterpreter,
332            ) if execution.exit_code != 0 => {
333                measured.set(false);
334                return Err(format!(
335                    "the test command exited {} before any test reported an outcome; its own output above says why, and no run was published",
336                    execution.exit_code
337                ));
338            }
339            // The interpreter or the runner the command used is not one
340            // Supercov measures: the message says what to change, and the
341            // evidence has nothing in it to diagnose.
342            Err(
343                error @ (crate::ruby_evidence::RubyEvidenceError::UnsupportedRuby(_)
344                | crate::ruby_evidence::RubyEvidenceError::NoTests
345                | crate::ruby_evidence::RubyEvidenceError::NoInterpreter),
346            ) => {
347                measured.set(false);
348                return Err(error.to_string());
349            }
350            result => result.map_err(|error| error.to_string())?,
351        };
352        validate_frontend_report_request(&run.declaration, &run.request)
353            .map_err(|error| error.to_string())?;
354        let archive_path = work_directory.join("evidence.raw.gz");
355        let entries = run.archive_entries().map_err(|error| error.to_string())?;
356        let entries = crate::source_capture::append(entries, &source_inputs)?;
357        let raw = write_archive(entries, &archive_path).map_err(|error| error.to_string())?;
358        remove_stored_tree_deferred(&root, &ruby_directory).map_err(|error| error.to_string())?;
359        let evidence_publication_ms = elapsed_ms(publication_started);
360        let timings = RunTimings {
361            initialization_ms,
362            workspace_preparation_ms: 0.0,
363            adapter_setup_ms,
364            instrumented_build_ms: 0.0,
365            test_command_ms,
366            evidence_publication_ms,
367        };
368        let metadata = RunMetadata {
369            id: request.run_id.clone(),
370            started_at: request.started_at.clone(),
371            duration_ms: elapsed_ms(total_started),
372            command: request.command.clone(),
373            test_exit_code: Some(execution.exit_code),
374            integrity,
375            raw_evidence: RawEvidenceMetadata {
376                schema_version: raw.schema_version,
377                format: raw.format.into(),
378                file: raw.file.into(),
379                files: raw.files,
380                uncompressed_bytes: raw.uncompressed_bytes,
381                compressed_bytes: raw.compressed_bytes,
382            },
383            isolated_build: None,
384            instrumented_build_cache: None,
385            timings: Some(timings),
386            merged: None,
387            parents: None,
388            source_roots: source_roots.clone(),
389            copy: None,
390            merged_across: None,
391            recorded_by: None,
392            companions: None,
393        };
394        let run_directory =
395            publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
396        finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
397        Ok(DirectRubyRunResult {
398            run_id: request.run_id.clone(),
399            run_directory,
400            exit_code: execution.exit_code,
401            tests: run.tests,
402            source_files: project.plan.files.len(),
403            interpreters: run.interpreters,
404            ruby_versions: run.ruby_versions,
405            recovered_runs,
406            metadata,
407        })
408    })();
409    let result = result.map_err(|error| {
410        if !measured.get() {
411            return error;
412        }
413        note_kept_evidence(
414            &root,
415            &work_directory.join("ruby/evidence"),
416            &request.run_id,
417            error,
418        )
419    });
420    if result.is_err() {
421        let _ = remove_stored_tree_deferred(&root, &work_directory);
422    }
423    let release = lock.release().map_err(|error| error.to_string());
424    match (result, release) {
425        (Ok(result), Ok(())) => Ok(result),
426        (Err(error), _) => Err(error),
427        (Ok(_), Err(error)) => Err(error),
428    }
429}
430
431/// Ruby measured inside a browser suite's run: the server its tests drive,
432/// which Playwright's `webServer` or a fixture starts. Prepared whenever the
433/// project has Ruby source; a run whose suite starts no measured Ruby
434/// publishes nothing of it.
435pub struct RubyServer {
436    project: PreparedRubyProject,
437    integrity: crate::run_store::RunIntegrity,
438    source_inputs: crate::source_manifest::Inputs,
439    source_roots: Option<Vec<String>>,
440    directory: PathBuf,
441}
442
443impl RubyServer {
444    /// The plan and runtime for the project's Ruby source, or None when it
445    /// has none. `workspace` is where the suite runs: the probes find the
446    /// server's files there.
447    pub fn prepare(
448        root: &Path,
449        workspace: &Path,
450        run_id: &str,
451        command: &[String],
452    ) -> Result<Option<Self>, String> {
453        let ambient = std::env::vars().collect::<std::collections::BTreeMap<_, _>>();
454        // Roots named for the JavaScript source may hold no Ruby at all.
455        let Ok(roots) =
456            crate::source_discovery::ExplicitSourceRoots::from_environment(root, &ambient)
457        else {
458            return Ok(None);
459        };
460        let project = prepare_ruby_project(root, roots.as_ref())?;
461        if project.plan.files.is_empty() {
462            return Ok(None);
463        }
464        let source_roots = crate::source_discovery::configured_source_roots(&ambient);
465        let mut integrity_inputs = ruby_integrity_inputs(&project.files, command);
466        crate::source_discovery::fold_roots_into_configuration(
467            &mut integrity_inputs.execution_configuration,
468            source_roots.as_deref(),
469        );
470        let integrity = create_explicit_run_integrity(
471            root,
472            &integrity_inputs,
473            &FrontendIntegrityInputs::embedded_ruby(),
474        )
475        .map_err(|error| error.to_string())?;
476        let source_inputs = crate::source_capture::capture(
477            root,
478            "ruby",
479            ruby_integrity_inputs(&project.files, command).assertion_paths(),
480        )?;
481        let directory = root.join(".supercov/work").join(run_id).join("ruby");
482        write_runtime(&directory.join("runtime"))?;
483        fs::create_dir_all(directory.join("evidence")).map_err(|error| error.to_string())?;
484        let mut plan = serde_json::to_value(&project.plan).map_err(|error| error.to_string())?;
485        plan["root"] = workspace.display().to_string().into();
486        let plan_path = directory.join("plan.rb");
487        fs::write(&plan_path, ruby_literal(&plan).into_bytes())
488            .map_err(|error| format!("{}: {error}", plan_path.display()))?;
489        Ok(Some(Self {
490            project,
491            integrity,
492            source_inputs,
493            source_roots,
494            directory,
495        }))
496    }
497
498    pub fn source_files(&self) -> usize {
499        self.project.plan.files.len()
500    }
501
502    /// What the suite's environment needs for a Ruby process it starts to be
503    /// measured; the run id and project root are the browser run's own.
504    pub fn environment(&self) -> std::collections::BTreeMap<String, String> {
505        let mut rubyopt = format!(
506            "-r{}",
507            self.directory.join("runtime/supercov_runtime.rb").display()
508        );
509        if let Some(existing) = std::env::var("RUBYOPT")
510            .ok()
511            .filter(|value| !value.is_empty())
512        {
513            rubyopt.push(' ');
514            rubyopt.push_str(&existing);
515        }
516        std::collections::BTreeMap::from([
517            ("RUBYOPT".into(), rubyopt),
518            (
519                "SUPERCOV_RUBY_PLAN".into(),
520                self.directory.join("plan.rb").display().to_string(),
521            ),
522            (
523                "SUPERCOV_RUBY_EVIDENCE_DIR".into(),
524                self.directory.join("evidence").display().to_string(),
525            ),
526            (
527                "SUPERCOV_RUBY_SOURCE_ROOT".into(),
528                self.project.root.display().to_string(),
529            ),
530        ])
531    }
532
533    /// Publish what the server ran as a Ruby run of its own, credited to the
534    /// browser tests that sent each request. None when no measured Ruby
535    /// process ran, or none answered a test's request.
536    pub fn publish(
537        self,
538        root: &Path,
539        evidence_run_id: &str,
540        metadata: RunMetadata,
541        served: &[crate::served_tests::ServedTest],
542        diagnostics: &mut dyn Write,
543    ) -> Result<Option<(PathBuf, RunMetadata)>, String> {
544        let evidence_directory = self.directory.join("evidence");
545        let result = (|| {
546            if served.is_empty() {
547                return Ok(None);
548            }
549            let mut run = match crate::ruby_evidence::build_ruby_server_run(
550                &self.project.manifest,
551                &evidence_directory,
552                evidence_run_id,
553                &metadata.started_at,
554                metadata.test_exit_code.unwrap_or_default(),
555                &self.project.root,
556                served,
557            ) {
558                Err(
559                    crate::ruby_evidence::RubyEvidenceError::NoInterpreter
560                    | crate::ruby_evidence::RubyEvidenceError::NoTests,
561                ) => return Ok(None),
562                result => result.map_err(|error| error.to_string())?,
563            };
564            let answered = run.request.raw_results.iter().any(|raw| {
565                raw.role == "test" && raw.runtime.iter().any(|snapshot| !snapshot.hits.is_empty())
566            });
567            if !answered {
568                writeln!(
569                    diagnostics,
570                    "[supercov] a Ruby process ran, and no request a test sent reached measured Ruby code; no Ruby run was published"
571                )
572                .map_err(|error| error.to_string())?;
573                return Ok(None);
574            }
575            validate_frontend_report_request(&run.declaration, &run.request)
576                .map_err(|error| error.to_string())?;
577            // The evidence was recorded under the browser run's id; a run
578            // published beside it has its own.
579            run.request.run_id = metadata.id.clone();
580            for scope in run
581                .request
582                .raw_results
583                .iter_mut()
584                .filter_map(|raw| raw.scope.as_mut())
585            {
586                scope.run_id = metadata.id.clone();
587            }
588            let archive_path = root
589                .join(".supercov/work")
590                .join(&metadata.id)
591                .join("ruby-evidence.raw.gz");
592            if let Some(parent) = archive_path.parent() {
593                fs::create_dir_all(parent).map_err(|error| error.to_string())?;
594            }
595            let entries = run.archive_entries().map_err(|error| error.to_string())?;
596            let entries = crate::source_capture::append(entries, &self.source_inputs)?;
597            let raw = write_archive(entries, &archive_path).map_err(|error| error.to_string())?;
598            let tests = run.tests;
599            drop(run);
600            let metadata = RunMetadata {
601                integrity: self.integrity.clone(),
602                raw_evidence: RawEvidenceMetadata {
603                    schema_version: raw.schema_version,
604                    format: raw.format.into(),
605                    file: raw.file.into(),
606                    files: raw.files,
607                    uncompressed_bytes: raw.uncompressed_bytes,
608                    compressed_bytes: raw.compressed_bytes,
609                },
610                source_roots: self.source_roots.clone(),
611                ..metadata
612            };
613            let run_directory =
614                publish_run(root, &metadata, &archive_path).map_err(|error| error.to_string())?;
615            writeln!(
616                diagnostics,
617                "[supercov] Ruby server coverage: {tests} test(s) across {} source file(s)",
618                self.project.plan.files.len()
619            )
620            .map_err(|error| error.to_string())?;
621            Ok(Some((run_directory, metadata)))
622        })();
623        let result = result.map_err(|error| {
624            note_kept_evidence(
625                root,
626                &evidence_directory,
627                &format!("{evidence_run_id}-ruby"),
628                error,
629            )
630        });
631        let _ = remove_stored_tree_deferred(root, &self.directory);
632        result
633    }
634}
635
636/// Render a JSON value as a Ruby literal: `nil`, booleans and numbers as
637/// themselves, strings double-quoted with JSON escapes (which Ruby shares)
638/// plus `#` escaped so nothing interpolates, arrays as `[..]` and objects as
639/// `{"key" => value, ..}` so keys stay strings rather than becoming symbols.
640pub(crate) fn ruby_literal(value: &serde_json::Value) -> String {
641    fn write(value: &serde_json::Value, out: &mut String) {
642        match value {
643            serde_json::Value::Null => out.push_str("nil"),
644            serde_json::Value::Bool(flag) => out.push_str(if *flag { "true" } else { "false" }),
645            serde_json::Value::Number(number) => out.push_str(&number.to_string()),
646            serde_json::Value::String(text) => write_string(text, out),
647            serde_json::Value::Array(items) => {
648                out.push('[');
649                for (index, item) in items.iter().enumerate() {
650                    if index > 0 {
651                        out.push(',');
652                    }
653                    write(item, out);
654                }
655                out.push(']');
656            }
657            serde_json::Value::Object(entries) => {
658                out.push('{');
659                for (index, (key, item)) in entries.iter().enumerate() {
660                    if index > 0 {
661                        out.push(',');
662                    }
663                    write_string(key, out);
664                    out.push_str("=>");
665                    write(item, out);
666                }
667                out.push('}');
668            }
669        }
670    }
671
672    fn write_string(text: &str, out: &mut String) {
673        let json = serde_json::to_string(text).unwrap_or_else(|_| "\"\"".into());
674        out.push_str(&json.replace('#', "\\#"));
675    }
676
677    let mut out = String::new();
678    write(value, &mut out);
679    out.push('\n');
680    out
681}
682
683#[cfg(test)]
684mod literal_tests {
685    use super::ruby_literal;
686
687    #[test]
688    fn ruby_literal_keeps_strings_inert_and_keys_as_strings() {
689        let value = serde_json::json!({
690            "a": [1, 2.5, -3, true, false, null],
691            "text": "quote \" backslash \\ interpolation #{x} tab \t unicode \u{e9}",
692            "nested": {"k": []}
693        });
694        let literal = ruby_literal(&value);
695        assert_eq!(
696            literal,
697            "{\"a\"=>[1,2.5,-3,true,false,nil],\"text\"=>\"quote \\\" backslash \\\\ interpolation \\#{x} tab \\t unicode \u{e9}\",\"nested\"=>{\"k\"=>[]}}\n"
698        );
699    }
700}