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_RUN_ID",
137        "SUPERCOV_PROJECT_ROOT",
138        "SUPERCOV_CONTEXT",
139        "SUPERCOV_RUBY_WORKER",
140    ] {
141        take(key);
142    }
143    variables.extend([
144        ("RUBYOPT".into(), rubyopt),
145        (
146            "SUPERCOV_RUBY_PLAN".into(),
147            plan_path.as_os_str().to_owned(),
148        ),
149        (
150            "SUPERCOV_RUBY_EVIDENCE_DIR".into(),
151            evidence_directory.as_os_str().to_owned(),
152        ),
153        ("SUPERCOV_RUN_ID".into(), run_id.into()),
154        ("SUPERCOV_PROJECT_ROOT".into(), root.as_os_str().to_owned()),
155    ]);
156    variables
157}
158
159/// The fingerprint a later query compares against the stored run: the same
160/// discovery and inputs the run used, without preparing a plan.
161pub fn current_ruby_integrity(
162    root: &Path,
163    command: &[String],
164    source_roots: Option<&[String]>,
165) -> Result<crate::run_store::RunIntegrity, String> {
166    let root = canonicalize_simplified(root).map_err(|error| error.to_string())?;
167    let mut files = crate::ruby_project::discover_ruby_files(&root)?;
168    // The roots the run was measured under, not this shell's: the run
169    // digested the files they kept, and nothing else would match it.
170    match source_roots.filter(|roots| !roots.is_empty()) {
171        Some(configured) => {
172            crate::source_discovery::ExplicitSourceRoots::resolve(&root, configured)
173                .map_err(|error| error.to_string())?
174                .narrow(&mut files.sources, &mut files.excluded, String::as_str);
175        }
176        None => crate::source_discovery::leave_out_what_lies_beside(
177            &mut files.sources,
178            &mut files.excluded,
179        ),
180    }
181    let mut inputs = ruby_integrity_inputs(&files, command);
182    crate::source_discovery::fold_roots_into_configuration(
183        &mut inputs.execution_configuration,
184        source_roots,
185    );
186    create_explicit_run_integrity(&root, &inputs, &FrontendIntegrityInputs::embedded_ruby())
187        .map_err(|error| error.to_string())
188}
189
190pub fn run_direct_ruby(
191    request: &DirectRubyRunRequest,
192    diagnostics: &mut dyn Write,
193) -> Result<DirectRubyRunResult, String> {
194    if request.command.is_empty() {
195        return Err("test command must not be empty".into());
196    }
197    let total_started = Instant::now();
198    let initialization_started = Instant::now();
199    let root = canonicalize_simplified(&request.root)
200        .map_err(|error| format!("{}: {error}", request.root.display()))?;
201    let mut lock = ProjectLock::acquire(&root, &request.run_id, &request.started_at)
202        .map_err(|error| error.to_string())?;
203    let initialization_ms = elapsed_ms(initialization_started);
204    let work_directory = root.join(".supercov/work").join(&request.run_id);
205    // Whether the tests were measured. A run that failed before that has no
206    // evidence worth keeping; one that failed after has evidence that cost the
207    // suite its wall clock.
208    let measured = std::cell::Cell::new(false);
209    let result = (|| {
210        let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
211            .map_err(|error| error.to_string())?;
212        if !recovered_runs.is_empty() {
213            writeln!(
214                diagnostics,
215                "[supercov] recovered abandoned run(s): {}",
216                recovered_runs.join(", ")
217            )
218            .map_err(|error| error.to_string())?;
219        }
220
221        let adapter_started = Instant::now();
222        let ambient = std::env::vars().collect::<std::collections::BTreeMap<_, _>>();
223        let roots = crate::source_discovery::ExplicitSourceRoots::from_environment(&root, &ambient)
224            .map_err(|error| error.to_string())?;
225        let project: PreparedRubyProject = prepare_ruby_project(&root, roots.as_ref())?;
226        let source_roots = crate::source_discovery::configured_source_roots(
227            &std::env::vars().collect::<std::collections::BTreeMap<_, _>>(),
228        );
229        let mut integrity_inputs = ruby_integrity_inputs(&project.files, &request.command);
230        crate::source_discovery::fold_roots_into_configuration(
231            &mut integrity_inputs.execution_configuration,
232            source_roots.as_deref(),
233        );
234        let integrity = create_explicit_run_integrity(
235            &root,
236            &integrity_inputs,
237            &FrontendIntegrityInputs::embedded_ruby(),
238        )
239        .map_err(|error| error.to_string())?;
240        let source_inputs = crate::source_capture::capture(
241            &root,
242            "ruby",
243            ruby_integrity_inputs(&project.files, &request.command).assertion_paths(),
244        )?;
245        let ruby_directory = work_directory.join("ruby");
246        let runtime_directory = ruby_directory.join("runtime");
247        let evidence_directory = ruby_directory.join("evidence");
248        // The plan is a Ruby literal rather than JSON: the runtime is
249        // required through `RUBYOPT` before Bundler runs, and loading the
250        // `json` default gem there would clash with the version an
251        // application's Gemfile pins.
252        let plan_path = ruby_directory.join("plan.rb");
253        write_runtime(&runtime_directory)?;
254        fs::create_dir_all(&evidence_directory).map_err(|error| error.to_string())?;
255        fs::write(
256            &plan_path,
257            ruby_literal(&serde_json::to_value(&project.plan).map_err(|error| error.to_string())?)
258                .into_bytes(),
259        )
260        .map_err(|error| format!("{}: {error}", plan_path.display()))?;
261        writeln!(
262            diagnostics,
263            "[supercov] detected Ruby; measuring {} source file(s) in place through Ruby's Coverage module and load-time probes",
264            project.plan.files.len()
265        )
266        .map_err(|error| error.to_string())?;
267        for (file, reason) in &project.unparseable {
268            writeln!(
269                diagnostics,
270                "[supercov] could not parse {file}: {reason}; it carries no obligations"
271            )
272            .map_err(|error| error.to_string())?;
273        }
274        let adapter_setup_ms = elapsed_ms(adapter_started);
275
276        let test_started = Instant::now();
277        let plan = ExecutionPlan {
278            preparation: Vec::new(),
279            test: ExecutionPhase {
280                name: "test".into(),
281                kind: PhaseKind::Test,
282                command: CommandSpec {
283                    program: request.command[0].clone().into(),
284                    arguments: request.command[1..].iter().map(OsString::from).collect(),
285                    cwd: root.clone(),
286                    environment: Some(environment(
287                        &root,
288                        &request.run_id,
289                        &runtime_directory,
290                        &plan_path,
291                        &evidence_directory,
292                    )),
293                    captured_output: None,
294                    console: None,
295                },
296            },
297        };
298        let options = SupervisionOptions::from_environment().map_err(|error| error.to_string())?;
299        let execution = execute_plan(&plan, options, diagnostics, |_, _| Ok(()))
300            .map_err(|error| error.to_string())?;
301        let test_command_ms = elapsed_ms(test_started);
302        if let Some(signal) = execution.interrupted_signal {
303            return Err(format!(
304                "the test command was interrupted by {signal:?}; no run was published"
305            ));
306        }
307
308        if std::env::var("SUPERCOV_KEEP_WORK").is_ok_and(|value| !value.is_empty()) {
309            // Kept before the join so a rejected evidence file stays inspectable.
310            let debug_directory = root.join(".supercov/ruby-debug").join(&request.run_id);
311            fs::create_dir_all(&debug_directory).map_err(|error| error.to_string())?;
312            copy_tree(&ruby_directory, &debug_directory)?;
313        }
314        let publication_started = Instant::now();
315        measured.set(true);
316        let run: RubyFrontendRun = match build_ruby_frontend_run(
317            &project.manifest,
318            &evidence_directory,
319            &request.run_id,
320            &request.started_at,
321            execution.exit_code,
322            &root,
323        ) {
324            // No test reported an outcome and the command itself failed: it
325            // never ran its tests (`No module named pytest`, an error while
326            // collecting). That is the command's failure, said in its own
327            // output, not a Supercov bug to report with evidence.
328            Err(
329                crate::ruby_evidence::RubyEvidenceError::NoTests
330                | crate::ruby_evidence::RubyEvidenceError::NoInterpreter,
331            ) if execution.exit_code != 0 => {
332                measured.set(false);
333                return Err(format!(
334                    "the test command exited {} before any test reported an outcome; its own output above says why, and no run was published",
335                    execution.exit_code
336                ));
337            }
338            // The interpreter or the runner the command used is not one
339            // Supercov measures: the message says what to change, and the
340            // evidence has nothing in it to diagnose.
341            Err(
342                error @ (crate::ruby_evidence::RubyEvidenceError::UnsupportedRuby(_)
343                | crate::ruby_evidence::RubyEvidenceError::NoTests
344                | crate::ruby_evidence::RubyEvidenceError::NoInterpreter),
345            ) => {
346                measured.set(false);
347                return Err(error.to_string());
348            }
349            result => result.map_err(|error| error.to_string())?,
350        };
351        validate_frontend_report_request(&run.declaration, &run.request)
352            .map_err(|error| error.to_string())?;
353        let archive_path = work_directory.join("evidence.raw.gz");
354        let entries = run.archive_entries().map_err(|error| error.to_string())?;
355        let entries = crate::source_capture::append(entries, &source_inputs)?;
356        let raw = write_archive(entries, &archive_path).map_err(|error| error.to_string())?;
357        remove_stored_tree_deferred(&root, &ruby_directory).map_err(|error| error.to_string())?;
358        let evidence_publication_ms = elapsed_ms(publication_started);
359        let timings = RunTimings {
360            initialization_ms,
361            workspace_preparation_ms: 0.0,
362            adapter_setup_ms,
363            instrumented_build_ms: 0.0,
364            test_command_ms,
365            evidence_publication_ms,
366        };
367        let metadata = RunMetadata {
368            id: request.run_id.clone(),
369            started_at: request.started_at.clone(),
370            duration_ms: elapsed_ms(total_started),
371            command: request.command.clone(),
372            test_exit_code: Some(execution.exit_code),
373            integrity,
374            raw_evidence: RawEvidenceMetadata {
375                schema_version: raw.schema_version,
376                format: raw.format.into(),
377                file: raw.file.into(),
378                files: raw.files,
379                uncompressed_bytes: raw.uncompressed_bytes,
380                compressed_bytes: raw.compressed_bytes,
381            },
382            isolated_build: None,
383            instrumented_build_cache: None,
384            timings: Some(timings),
385            merged: None,
386            parents: None,
387            source_roots: source_roots.clone(),
388            copy: None,
389            merged_across: None,
390        };
391        let run_directory =
392            publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
393        finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
394        Ok(DirectRubyRunResult {
395            run_id: request.run_id.clone(),
396            run_directory,
397            exit_code: execution.exit_code,
398            tests: run.tests,
399            source_files: project.plan.files.len(),
400            interpreters: run.interpreters,
401            ruby_versions: run.ruby_versions,
402            recovered_runs,
403            metadata,
404        })
405    })();
406    let result = result.map_err(|error| {
407        if !measured.get() {
408            return error;
409        }
410        note_kept_evidence(
411            &root,
412            &work_directory.join("ruby/evidence"),
413            &request.run_id,
414            error,
415        )
416    });
417    if result.is_err() {
418        let _ = remove_stored_tree_deferred(&root, &work_directory);
419    }
420    let release = lock.release().map_err(|error| error.to_string());
421    match (result, release) {
422        (Ok(result), Ok(())) => Ok(result),
423        (Err(error), _) => Err(error),
424        (Ok(_), Err(error)) => Err(error),
425    }
426}
427
428/// Render a JSON value as a Ruby literal: `nil`, booleans and numbers as
429/// themselves, strings double-quoted with JSON escapes (which Ruby shares)
430/// plus `#` escaped so nothing interpolates, arrays as `[..]` and objects as
431/// `{"key" => value, ..}` so keys stay strings rather than becoming symbols.
432pub(crate) fn ruby_literal(value: &serde_json::Value) -> String {
433    fn write(value: &serde_json::Value, out: &mut String) {
434        match value {
435            serde_json::Value::Null => out.push_str("nil"),
436            serde_json::Value::Bool(flag) => out.push_str(if *flag { "true" } else { "false" }),
437            serde_json::Value::Number(number) => out.push_str(&number.to_string()),
438            serde_json::Value::String(text) => write_string(text, out),
439            serde_json::Value::Array(items) => {
440                out.push('[');
441                for (index, item) in items.iter().enumerate() {
442                    if index > 0 {
443                        out.push(',');
444                    }
445                    write(item, out);
446                }
447                out.push(']');
448            }
449            serde_json::Value::Object(entries) => {
450                out.push('{');
451                for (index, (key, item)) in entries.iter().enumerate() {
452                    if index > 0 {
453                        out.push(',');
454                    }
455                    write_string(key, out);
456                    out.push_str("=>");
457                    write(item, out);
458                }
459                out.push('}');
460            }
461        }
462    }
463
464    fn write_string(text: &str, out: &mut String) {
465        let json = serde_json::to_string(text).unwrap_or_else(|_| "\"\"".into());
466        out.push_str(&json.replace('#', "\\#"));
467    }
468
469    let mut out = String::new();
470    write(value, &mut out);
471    out.push('\n');
472    out
473}
474
475#[cfg(test)]
476mod literal_tests {
477    use super::ruby_literal;
478
479    #[test]
480    fn ruby_literal_keeps_strings_inert_and_keys_as_strings() {
481        let value = serde_json::json!({
482            "a": [1, 2.5, -3, true, false, null],
483            "text": "quote \" backslash \\ interpolation #{x} tab \t unicode \u{e9}",
484            "nested": {"k": []}
485        });
486        let literal = ruby_literal(&value);
487        assert_eq!(
488            literal,
489            "{\"a\"=>[1,2.5,-3,true,false,nil],\"text\"=>\"quote \\\" backslash \\\\ interpolation \\#{x} tab \\t unicode \u{e9}\",\"nested\"=>{\"k\"=>[]}}\n"
490        );
491    }
492}