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, publish_run, recover_abandoned_runs,
26        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) -> Result<crate::run_store::RunIntegrity, String> {
165    let root = canonicalize_simplified(root).map_err(|error| error.to_string())?;
166    let files = crate::ruby_project::discover_ruby_files(&root)?;
167    create_explicit_run_integrity(
168        &root,
169        &ruby_integrity_inputs(&files, command),
170        &FrontendIntegrityInputs::embedded_ruby(),
171    )
172    .map_err(|error| error.to_string())
173}
174
175pub fn run_direct_ruby(
176    request: &DirectRubyRunRequest,
177    diagnostics: &mut dyn Write,
178) -> Result<DirectRubyRunResult, String> {
179    if request.command.is_empty() {
180        return Err("test command must not be empty".into());
181    }
182    let total_started = Instant::now();
183    let initialization_started = Instant::now();
184    let root = canonicalize_simplified(&request.root)
185        .map_err(|error| format!("{}: {error}", request.root.display()))?;
186    let mut lock = ProjectLock::acquire(&root, &request.run_id, &request.started_at)
187        .map_err(|error| error.to_string())?;
188    let initialization_ms = elapsed_ms(initialization_started);
189    let work_directory = root.join(".supercov/work").join(&request.run_id);
190    let result = (|| {
191        let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
192            .map_err(|error| error.to_string())?;
193        if !recovered_runs.is_empty() {
194            writeln!(
195                diagnostics,
196                "[supercov] recovered abandoned run(s): {}",
197                recovered_runs.join(", ")
198            )
199            .map_err(|error| error.to_string())?;
200        }
201
202        let adapter_started = Instant::now();
203        let project: PreparedRubyProject = prepare_ruby_project(&root)?;
204        let integrity = create_explicit_run_integrity(
205            &root,
206            &ruby_integrity_inputs(&project.files, &request.command),
207            &FrontendIntegrityInputs::embedded_ruby(),
208        )
209        .map_err(|error| error.to_string())?;
210        let assertion_inputs = crate::assertion_inputs::capture(
211            &root,
212            "ruby",
213            ruby_integrity_inputs(&project.files, &request.command).assertion_paths(),
214        )?;
215        let ruby_directory = work_directory.join("ruby");
216        let runtime_directory = ruby_directory.join("runtime");
217        let evidence_directory = ruby_directory.join("evidence");
218        // The plan is a Ruby literal rather than JSON: the runtime is
219        // required through `RUBYOPT` before Bundler runs, and loading the
220        // `json` default gem there would clash with the version an
221        // application's Gemfile pins.
222        let plan_path = ruby_directory.join("plan.rb");
223        write_runtime(&runtime_directory)?;
224        fs::create_dir_all(&evidence_directory).map_err(|error| error.to_string())?;
225        fs::write(
226            &plan_path,
227            ruby_literal(&serde_json::to_value(&project.plan).map_err(|error| error.to_string())?)
228                .into_bytes(),
229        )
230        .map_err(|error| format!("{}: {error}", plan_path.display()))?;
231        writeln!(
232            diagnostics,
233            "[supercov] detected Ruby; measuring {} source file(s) in place through Ruby's Coverage module and load-time probes",
234            project.plan.files.len()
235        )
236        .map_err(|error| error.to_string())?;
237        for (file, reason) in &project.unparseable {
238            writeln!(
239                diagnostics,
240                "[supercov] could not parse {file}: {reason}; it carries no obligations"
241            )
242            .map_err(|error| error.to_string())?;
243        }
244        let adapter_setup_ms = elapsed_ms(adapter_started);
245
246        let test_started = Instant::now();
247        let plan = ExecutionPlan {
248            preparation: Vec::new(),
249            test: ExecutionPhase {
250                name: "test".into(),
251                kind: PhaseKind::Test,
252                command: CommandSpec {
253                    program: request.command[0].clone().into(),
254                    arguments: request.command[1..].iter().map(OsString::from).collect(),
255                    cwd: root.clone(),
256                    environment: Some(environment(
257                        &root,
258                        &request.run_id,
259                        &runtime_directory,
260                        &plan_path,
261                        &evidence_directory,
262                    )),
263                    captured_output: None,
264                },
265            },
266        };
267        let options = SupervisionOptions::from_environment().map_err(|error| error.to_string())?;
268        let execution = execute_plan(&plan, options, diagnostics, |_, _| Ok(()))
269            .map_err(|error| error.to_string())?;
270        let test_command_ms = elapsed_ms(test_started);
271        if let Some(signal) = execution.interrupted_signal {
272            return Err(format!(
273                "the test command was interrupted by {signal:?}; no run was published"
274            ));
275        }
276
277        if std::env::var("SUPERCOV_KEEP_WORK").is_ok_and(|value| !value.is_empty()) {
278            // Kept before the join so a rejected evidence file stays inspectable.
279            let debug_directory = root.join(".supercov/ruby-debug").join(&request.run_id);
280            fs::create_dir_all(&debug_directory).map_err(|error| error.to_string())?;
281            copy_tree(&ruby_directory, &debug_directory)?;
282        }
283        let publication_started = Instant::now();
284        let run: RubyFrontendRun = build_ruby_frontend_run(
285            &project.manifest,
286            &evidence_directory,
287            &request.run_id,
288            &request.started_at,
289            execution.exit_code,
290        )
291        .map_err(|error| error.to_string())?;
292        validate_frontend_report_request(&run.declaration, &run.request)
293            .map_err(|error| error.to_string())?;
294        let archive_path = work_directory.join("evidence.raw.gz");
295        let entries = run.archive_entries().map_err(|error| error.to_string())?;
296        let entries = crate::assertion_inputs::append(entries, &assertion_inputs)?;
297        let raw = write_archive(entries, &archive_path).map_err(|error| error.to_string())?;
298        remove_stored_tree_deferred(&root, &ruby_directory).map_err(|error| error.to_string())?;
299        let evidence_publication_ms = elapsed_ms(publication_started);
300        let timings = RunTimings {
301            initialization_ms,
302            workspace_preparation_ms: 0.0,
303            adapter_setup_ms,
304            instrumented_build_ms: 0.0,
305            test_command_ms,
306            evidence_publication_ms,
307        };
308        let metadata = RunMetadata {
309            id: request.run_id.clone(),
310            started_at: request.started_at.clone(),
311            duration_ms: elapsed_ms(total_started),
312            command: request.command.clone(),
313            test_exit_code: Some(execution.exit_code),
314            integrity,
315            raw_evidence: RawEvidenceMetadata {
316                schema_version: raw.schema_version,
317                format: raw.format.into(),
318                file: raw.file.into(),
319                files: raw.files,
320                uncompressed_bytes: raw.uncompressed_bytes,
321                compressed_bytes: raw.compressed_bytes,
322            },
323            isolated_build: None,
324            instrumented_build_cache: None,
325            timings: Some(timings),
326            merged: None,
327            parents: None,
328        };
329        let run_directory =
330            publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
331        finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
332        Ok(DirectRubyRunResult {
333            run_id: request.run_id.clone(),
334            run_directory,
335            exit_code: execution.exit_code,
336            tests: run.tests,
337            source_files: project.plan.files.len(),
338            interpreters: run.interpreters,
339            ruby_versions: run.ruby_versions,
340            recovered_runs,
341            metadata,
342        })
343    })();
344    if result.is_err() {
345        let _ = remove_stored_tree_deferred(&root, &work_directory);
346    }
347    let release = lock.release().map_err(|error| error.to_string());
348    match (result, release) {
349        (Ok(result), Ok(())) => Ok(result),
350        (Err(error), _) => Err(error),
351        (Ok(_), Err(error)) => Err(error),
352    }
353}
354
355/// Render a JSON value as a Ruby literal: `nil`, booleans and numbers as
356/// themselves, strings double-quoted with JSON escapes (which Ruby shares)
357/// plus `#` escaped so nothing interpolates, arrays as `[..]` and objects as
358/// `{"key" => value, ..}` so keys stay strings rather than becoming symbols.
359pub(crate) fn ruby_literal(value: &serde_json::Value) -> String {
360    fn write(value: &serde_json::Value, out: &mut String) {
361        match value {
362            serde_json::Value::Null => out.push_str("nil"),
363            serde_json::Value::Bool(flag) => out.push_str(if *flag { "true" } else { "false" }),
364            serde_json::Value::Number(number) => out.push_str(&number.to_string()),
365            serde_json::Value::String(text) => write_string(text, out),
366            serde_json::Value::Array(items) => {
367                out.push('[');
368                for (index, item) in items.iter().enumerate() {
369                    if index > 0 {
370                        out.push(',');
371                    }
372                    write(item, out);
373                }
374                out.push(']');
375            }
376            serde_json::Value::Object(entries) => {
377                out.push('{');
378                for (index, (key, item)) in entries.iter().enumerate() {
379                    if index > 0 {
380                        out.push(',');
381                    }
382                    write_string(key, out);
383                    out.push_str("=>");
384                    write(item, out);
385                }
386                out.push('}');
387            }
388        }
389    }
390
391    fn write_string(text: &str, out: &mut String) {
392        let json = serde_json::to_string(text).unwrap_or_else(|_| "\"\"".into());
393        out.push_str(&json.replace('#', "\\#"));
394    }
395
396    let mut out = String::new();
397    write(value, &mut out);
398    out.push('\n');
399    out
400}
401
402#[cfg(test)]
403mod literal_tests {
404    use super::ruby_literal;
405
406    #[test]
407    fn ruby_literal_keeps_strings_inert_and_keys_as_strings() {
408        let value = serde_json::json!({
409            "a": [1, 2.5, -3, true, false, null],
410            "text": "quote \" backslash \\ interpolation #{x} tab \t unicode \u{e9}",
411            "nested": {"k": []}
412        });
413        let literal = ruby_literal(&value);
414        assert_eq!(
415            literal,
416            "{\"a\"=>[1,2.5,-3,true,false,nil],\"text\"=>\"quote \\\" backslash \\\\ interpolation \\#{x} tab \\t unicode \u{e9}\",\"nested\"=>{\"k\"=>[]}}\n"
417        );
418    }
419}