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::{
20 evidence_archive::write_archive,
21 frontend_protocol::validate_frontend_report_request,
22 integrity::{FrontendIntegrityInputs, create_explicit_run_integrity},
23 lifecycle::{
24 ProjectLock, finalize_published_run, publish_run, recover_abandoned_runs,
25 remove_stored_tree_deferred,
26 },
27 orchestration::{ExecutionPhase, ExecutionPlan, PhaseKind, execute_plan},
28 process_supervision::{CommandSpec, SupervisionOptions},
29 ruby_evidence::{RubyFrontendRun, build_ruby_frontend_run},
30 ruby_project::{PreparedRubyProject, prepare_ruby_project, ruby_integrity_inputs},
31 run_store::{RawEvidenceMetadata, RunMetadata, RunTimings},
32};
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(rename_all = "camelCase", deny_unknown_fields)]
36pub struct DirectRubyRunRequest {
37 pub root: PathBuf,
38 pub command: Vec<String>,
39 pub run_id: String,
40 pub started_at: String,
41}
42
43#[derive(Debug, Clone, PartialEq)]
44pub struct DirectRubyRunResult {
45 pub run_id: String,
46 pub run_directory: PathBuf,
47 pub exit_code: i32,
48 pub tests: usize,
49 pub source_files: usize,
50 pub interpreters: usize,
51 pub ruby_versions: Vec<String>,
52 pub recovered_runs: Vec<String>,
53 pub metadata: RunMetadata,
54}
55
56fn elapsed_ms(started: Instant) -> f64 {
57 (started.elapsed().as_secs_f64() * 10_000.0).round() / 10.0
58}
59
60fn embedded_runtime_files() -> [(&'static str, &'static [u8]); 5] {
61 [
62 (
63 "supercov_runtime.rb",
64 include_bytes!("../runtime-assets/ruby/supercov_runtime.rb"),
65 ),
66 (
67 "supercov_rspec.rb",
68 include_bytes!("../runtime-assets/ruby/supercov_rspec.rb"),
69 ),
70 (
71 "supercov_minitest.rb",
72 include_bytes!("../runtime-assets/ruby/supercov_minitest.rb"),
73 ),
74 (
75 "supercov_testunit.rb",
76 include_bytes!("../runtime-assets/ruby/supercov_testunit.rb"),
77 ),
78 (
79 "supercov_cucumber.rb",
80 include_bytes!("../runtime-assets/ruby/supercov_cucumber.rb"),
81 ),
82 ]
83}
84
85fn write_runtime(directory: &Path) -> Result<(), String> {
86 fs::create_dir_all(directory).map_err(|error| format!("{}: {error}", directory.display()))?;
87 for (name, contents) in embedded_runtime_files() {
88 let path = directory.join(name);
89 fs::write(&path, contents).map_err(|error| format!("{}: {error}", path.display()))?;
90 }
91 Ok(())
92}
93
94fn copy_tree(source: &Path, destination: &Path) -> Result<(), String> {
95 for entry in fs::read_dir(source).map_err(|error| format!("{}: {error}", source.display()))? {
96 let entry = entry.map_err(|error| error.to_string())?;
97 let target = destination.join(entry.file_name());
98 if entry
99 .file_type()
100 .map_err(|error| error.to_string())?
101 .is_dir()
102 {
103 fs::create_dir_all(&target).map_err(|error| error.to_string())?;
104 copy_tree(&entry.path(), &target)?;
105 } else {
106 fs::copy(entry.path(), &target).map_err(|error| error.to_string())?;
107 }
108 }
109 Ok(())
110}
111
112fn environment(
113 root: &Path,
114 run_id: &str,
115 runtime_directory: &Path,
116 plan_path: &Path,
117 evidence_directory: &Path,
118) -> Vec<(OsString, OsString)> {
119 let mut variables = std::env::vars_os().collect::<Vec<_>>();
120 let mut take = |key: &str| {
121 let position = variables.iter().position(|(name, _)| name == key);
122 position.map(|index| variables.remove(index).1)
123 };
124 let mut rubyopt = OsString::from("-r");
127 rubyopt.push(runtime_directory.join("supercov_runtime.rb"));
128 if let Some(existing) = take("RUBYOPT").filter(|existing| !existing.is_empty()) {
129 rubyopt.push(" ");
130 rubyopt.push(existing);
131 }
132 for key in [
133 "SUPERCOV_RUBY_PLAN",
134 "SUPERCOV_RUBY_EVIDENCE_DIR",
135 "SUPERCOV_RUN_ID",
136 "SUPERCOV_PROJECT_ROOT",
137 "SUPERCOV_CONTEXT",
138 "SUPERCOV_RUBY_WORKER",
139 ] {
140 take(key);
141 }
142 variables.extend([
143 ("RUBYOPT".into(), rubyopt),
144 (
145 "SUPERCOV_RUBY_PLAN".into(),
146 plan_path.as_os_str().to_owned(),
147 ),
148 (
149 "SUPERCOV_RUBY_EVIDENCE_DIR".into(),
150 evidence_directory.as_os_str().to_owned(),
151 ),
152 ("SUPERCOV_RUN_ID".into(), run_id.into()),
153 ("SUPERCOV_PROJECT_ROOT".into(), root.as_os_str().to_owned()),
154 ]);
155 variables
156}
157
158pub fn current_ruby_integrity(
161 root: &Path,
162 command: &[String],
163) -> Result<crate::run_store::RunIntegrity, String> {
164 let root = fs::canonicalize(root).map_err(|error| error.to_string())?;
165 let files = crate::ruby_project::discover_ruby_files(&root)?;
166 create_explicit_run_integrity(
167 &root,
168 &ruby_integrity_inputs(&files, command),
169 &FrontendIntegrityInputs::embedded_ruby(),
170 )
171 .map_err(|error| error.to_string())
172}
173
174pub fn run_direct_ruby(
175 request: &DirectRubyRunRequest,
176 diagnostics: &mut dyn Write,
177) -> Result<DirectRubyRunResult, String> {
178 if request.command.is_empty() {
179 return Err("test command must not be empty".into());
180 }
181 let total_started = Instant::now();
182 let initialization_started = Instant::now();
183 let root = fs::canonicalize(&request.root)
184 .map_err(|error| format!("{}: {error}", request.root.display()))?;
185 let mut lock = ProjectLock::acquire(&root, &request.run_id, &request.started_at)
186 .map_err(|error| error.to_string())?;
187 let initialization_ms = elapsed_ms(initialization_started);
188 let work_directory = root.join(".supercov/work").join(&request.run_id);
189 let result = (|| {
190 let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
191 .map_err(|error| error.to_string())?;
192 if !recovered_runs.is_empty() {
193 writeln!(
194 diagnostics,
195 "[supercov] recovered abandoned run(s): {}",
196 recovered_runs.join(", ")
197 )
198 .map_err(|error| error.to_string())?;
199 }
200
201 let adapter_started = Instant::now();
202 let project: PreparedRubyProject = prepare_ruby_project(&root)?;
203 let integrity = create_explicit_run_integrity(
204 &root,
205 &ruby_integrity_inputs(&project.files, &request.command),
206 &FrontendIntegrityInputs::embedded_ruby(),
207 )
208 .map_err(|error| error.to_string())?;
209 let ruby_directory = work_directory.join("ruby");
210 let runtime_directory = ruby_directory.join("runtime");
211 let evidence_directory = ruby_directory.join("evidence");
212 let plan_path = ruby_directory.join("plan.rb");
217 write_runtime(&runtime_directory)?;
218 fs::create_dir_all(&evidence_directory).map_err(|error| error.to_string())?;
219 fs::write(
220 &plan_path,
221 ruby_literal(&serde_json::to_value(&project.plan).map_err(|error| error.to_string())?)
222 .into_bytes(),
223 )
224 .map_err(|error| format!("{}: {error}", plan_path.display()))?;
225 writeln!(
226 diagnostics,
227 "[supercov] detected Ruby; measuring {} source file(s) in place through Ruby's Coverage module and load-time probes",
228 project.plan.files.len()
229 )
230 .map_err(|error| error.to_string())?;
231 for (file, reason) in &project.unparseable {
232 writeln!(
233 diagnostics,
234 "[supercov] could not parse {file}: {reason}; it carries no obligations"
235 )
236 .map_err(|error| error.to_string())?;
237 }
238 let adapter_setup_ms = elapsed_ms(adapter_started);
239
240 let test_started = Instant::now();
241 let plan = ExecutionPlan {
242 preparation: Vec::new(),
243 test: ExecutionPhase {
244 name: "test".into(),
245 kind: PhaseKind::Test,
246 command: CommandSpec {
247 program: request.command[0].clone().into(),
248 arguments: request.command[1..].iter().map(OsString::from).collect(),
249 cwd: root.clone(),
250 environment: Some(environment(
251 &root,
252 &request.run_id,
253 &runtime_directory,
254 &plan_path,
255 &evidence_directory,
256 )),
257 captured_output: None,
258 },
259 },
260 };
261 let options = SupervisionOptions::from_environment().map_err(|error| error.to_string())?;
262 let execution = execute_plan(&plan, options, diagnostics, |_, _| Ok(()))
263 .map_err(|error| error.to_string())?;
264 let test_command_ms = elapsed_ms(test_started);
265 if let Some(signal) = execution.interrupted_signal {
266 return Err(format!(
267 "the test command was interrupted by {signal:?}; no run was published"
268 ));
269 }
270
271 if std::env::var("SUPERCOV_KEEP_WORK").is_ok_and(|value| !value.is_empty()) {
272 let debug_directory = root.join(".supercov/ruby-debug").join(&request.run_id);
274 fs::create_dir_all(&debug_directory).map_err(|error| error.to_string())?;
275 copy_tree(&ruby_directory, &debug_directory)?;
276 }
277 let publication_started = Instant::now();
278 let run: RubyFrontendRun = build_ruby_frontend_run(
279 &project.manifest,
280 &evidence_directory,
281 &request.run_id,
282 &request.started_at,
283 execution.exit_code,
284 )
285 .map_err(|error| error.to_string())?;
286 validate_frontend_report_request(&run.declaration, &run.request)
287 .map_err(|error| error.to_string())?;
288 let archive_path = work_directory.join("evidence.raw.gz");
289 let entries = run.archive_entries().map_err(|error| error.to_string())?;
290 let raw = write_archive(entries, &archive_path).map_err(|error| error.to_string())?;
291 remove_stored_tree_deferred(&root, &ruby_directory).map_err(|error| error.to_string())?;
292 let evidence_publication_ms = elapsed_ms(publication_started);
293 let timings = RunTimings {
294 initialization_ms,
295 workspace_preparation_ms: 0.0,
296 adapter_setup_ms,
297 instrumented_build_ms: 0.0,
298 test_command_ms,
299 evidence_publication_ms,
300 };
301 let metadata = RunMetadata {
302 id: request.run_id.clone(),
303 started_at: request.started_at.clone(),
304 duration_ms: elapsed_ms(total_started),
305 command: request.command.clone(),
306 test_exit_code: Some(execution.exit_code),
307 integrity,
308 raw_evidence: RawEvidenceMetadata {
309 schema_version: raw.schema_version,
310 format: raw.format.into(),
311 file: raw.file.into(),
312 files: raw.files,
313 uncompressed_bytes: raw.uncompressed_bytes,
314 compressed_bytes: raw.compressed_bytes,
315 },
316 isolated_build: None,
317 instrumented_build_cache: None,
318 timings: Some(timings),
319 merged: None,
320 parents: None,
321 };
322 let run_directory =
323 publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
324 finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
325 Ok(DirectRubyRunResult {
326 run_id: request.run_id.clone(),
327 run_directory,
328 exit_code: execution.exit_code,
329 tests: run.tests,
330 source_files: project.plan.files.len(),
331 interpreters: run.interpreters,
332 ruby_versions: run.ruby_versions,
333 recovered_runs,
334 metadata,
335 })
336 })();
337 if result.is_err() {
338 let _ = remove_stored_tree_deferred(&root, &work_directory);
339 }
340 let release = lock.release().map_err(|error| error.to_string());
341 match (result, release) {
342 (Ok(result), Ok(())) => Ok(result),
343 (Err(error), _) => Err(error),
344 (Ok(_), Err(error)) => Err(error),
345 }
346}
347
348pub(crate) fn ruby_literal(value: &serde_json::Value) -> String {
353 fn write(value: &serde_json::Value, out: &mut String) {
354 match value {
355 serde_json::Value::Null => out.push_str("nil"),
356 serde_json::Value::Bool(flag) => out.push_str(if *flag { "true" } else { "false" }),
357 serde_json::Value::Number(number) => out.push_str(&number.to_string()),
358 serde_json::Value::String(text) => write_string(text, out),
359 serde_json::Value::Array(items) => {
360 out.push('[');
361 for (index, item) in items.iter().enumerate() {
362 if index > 0 {
363 out.push(',');
364 }
365 write(item, out);
366 }
367 out.push(']');
368 }
369 serde_json::Value::Object(entries) => {
370 out.push('{');
371 for (index, (key, item)) in entries.iter().enumerate() {
372 if index > 0 {
373 out.push(',');
374 }
375 write_string(key, out);
376 out.push_str("=>");
377 write(item, out);
378 }
379 out.push('}');
380 }
381 }
382 }
383
384 fn write_string(text: &str, out: &mut String) {
385 let json = serde_json::to_string(text).unwrap_or_else(|_| "\"\"".into());
386 out.push_str(&json.replace('#', "\\#"));
387 }
388
389 let mut out = String::new();
390 write(value, &mut out);
391 out.push('\n');
392 out
393}
394
395#[cfg(test)]
396mod literal_tests {
397 use super::ruby_literal;
398
399 #[test]
400 fn ruby_literal_keeps_strings_inert_and_keys_as_strings() {
401 let value = serde_json::json!({
402 "a": [1, 2.5, -3, true, false, null],
403 "text": "quote \" backslash \\ interpolation #{x} tab \t unicode \u{e9}",
404 "nested": {"k": []}
405 });
406 let literal = ruby_literal(&value);
407 assert_eq!(
408 literal,
409 "{\"a\"=>[1,2.5,-3,true,false,nil],\"text\"=>\"quote \\\" backslash \\\\ interpolation \\#{x} tab \\t unicode \u{e9}\",\"nested\"=>{\"k\"=>[]}}\n"
410 );
411 }
412}