1use 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 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
159pub 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 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 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 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 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 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 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 };
389 let run_directory =
390 publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
391 finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
392 Ok(DirectRubyRunResult {
393 run_id: request.run_id.clone(),
394 run_directory,
395 exit_code: execution.exit_code,
396 tests: run.tests,
397 source_files: project.plan.files.len(),
398 interpreters: run.interpreters,
399 ruby_versions: run.ruby_versions,
400 recovered_runs,
401 metadata,
402 })
403 })();
404 let result = result.map_err(|error| {
405 if !measured.get() {
406 return error;
407 }
408 note_kept_evidence(
409 &root,
410 &work_directory.join("ruby/evidence"),
411 &request.run_id,
412 error,
413 )
414 });
415 if result.is_err() {
416 let _ = remove_stored_tree_deferred(&root, &work_directory);
417 }
418 let release = lock.release().map_err(|error| error.to_string());
419 match (result, release) {
420 (Ok(result), Ok(())) => Ok(result),
421 (Err(error), _) => Err(error),
422 (Ok(_), Err(error)) => Err(error),
423 }
424}
425
426pub(crate) fn ruby_literal(value: &serde_json::Value) -> String {
431 fn write(value: &serde_json::Value, out: &mut String) {
432 match value {
433 serde_json::Value::Null => out.push_str("nil"),
434 serde_json::Value::Bool(flag) => out.push_str(if *flag { "true" } else { "false" }),
435 serde_json::Value::Number(number) => out.push_str(&number.to_string()),
436 serde_json::Value::String(text) => write_string(text, out),
437 serde_json::Value::Array(items) => {
438 out.push('[');
439 for (index, item) in items.iter().enumerate() {
440 if index > 0 {
441 out.push(',');
442 }
443 write(item, out);
444 }
445 out.push(']');
446 }
447 serde_json::Value::Object(entries) => {
448 out.push('{');
449 for (index, (key, item)) in entries.iter().enumerate() {
450 if index > 0 {
451 out.push(',');
452 }
453 write_string(key, out);
454 out.push_str("=>");
455 write(item, out);
456 }
457 out.push('}');
458 }
459 }
460 }
461
462 fn write_string(text: &str, out: &mut String) {
463 let json = serde_json::to_string(text).unwrap_or_else(|_| "\"\"".into());
464 out.push_str(&json.replace('#', "\\#"));
465 }
466
467 let mut out = String::new();
468 write(value, &mut out);
469 out.push('\n');
470 out
471}
472
473#[cfg(test)]
474mod literal_tests {
475 use super::ruby_literal;
476
477 #[test]
478 fn ruby_literal_keeps_strings_inert_and_keys_as_strings() {
479 let value = serde_json::json!({
480 "a": [1, 2.5, -3, true, false, null],
481 "text": "quote \" backslash \\ interpolation #{x} tab \t unicode \u{e9}",
482 "nested": {"k": []}
483 });
484 let literal = ruby_literal(&value);
485 assert_eq!(
486 literal,
487 "{\"a\"=>[1,2.5,-3,true,false,nil],\"text\"=>\"quote \\\" backslash \\\\ interpolation \\#{x} tab \\t unicode \u{e9}\",\"nested\"=>{\"k\"=>[]}}\n"
488 );
489 }
490}