1use std::{
21 collections::{BTreeMap, BTreeSet},
22 ffi::OsString,
23 fs,
24 io::Write,
25 path::{Path, PathBuf},
26 time::Instant,
27};
28
29use serde::{Deserialize, Serialize};
30
31use crate::{
32 evidence_archive::write_archive,
33 frontend_protocol::validate_frontend_report_request,
34 go_instrumenter::{RUNTIME_ALIAS, RUNTIME_IMPORT, rewrite},
35 go_project::{PreparedGoProject, go_integrity_inputs, module_path, prepare_go_project},
36 go_test_harness::{instrument_test_file, probe_array_file, synthesized_harness},
37 integrity::{FrontendIntegrityInputs, create_explicit_run_integrity},
38 lifecycle::{
39 ProjectLock, finalize_published_run, publish_run, recover_abandoned_runs,
40 remove_stored_tree_deferred,
41 },
42 orchestration::{ExecutionPhase, ExecutionPlan, PhaseKind, execute_plan},
43 owned_evidence::{
44 OwnedRunInputs, OwnedTestOutcome, build_frontend_run, go_coverage_model, go_declaration,
45 merge_evidence, read_evidence,
46 },
47 process_supervision::{CommandSpec, SupervisionOptions},
48 run_store::{RawEvidenceMetadata, RunMetadata, RunTimings},
49 workspace::{canonicalize_simplified, prepare_cached_workspace, recover_cached_workspace},
50};
51
52const RUNTIME_DIRECTORY: &str = "supercov_runtime";
58
59const RUNTIME_SOURCE: &str = include_str!("../runtime-assets/go/supercov/supercov.go");
60
61const PROBE_FILE: &str = "supercov_probes.go";
63
64const HARNESS_FILE: &str = "supercov_generated_test.go";
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "camelCase", deny_unknown_fields)]
69pub struct DirectGoRunRequest {
70 pub root: PathBuf,
71 pub command: Vec<String>,
72 pub run_id: String,
73 pub started_at: String,
74}
75
76#[derive(Debug, Clone, PartialEq)]
77pub struct DirectGoRunResult {
78 pub run_id: String,
79 pub run_directory: PathBuf,
80 pub exit_code: i32,
81 pub tests: usize,
82 pub source_files: usize,
83 pub packages: usize,
84 pub recovered_runs: Vec<String>,
85 pub metadata: RunMetadata,
86}
87
88fn elapsed_ms(started: Instant) -> f64 {
89 (started.elapsed().as_secs_f64() * 10_000.0).round() / 10.0
90}
91
92#[derive(Debug, Clone)]
94struct GoTestPackage {
95 directory: String,
98 evidence: PathBuf,
99 declared_in: BTreeMap<String, String>,
101}
102
103struct InstrumentedWorkspace {
104 project: PreparedGoProject,
105 packages: Vec<GoTestPackage>,
106}
107
108fn directory_of(relative: &str) -> String {
109 match relative.rsplit_once('/') {
110 Some((directory, _)) => directory.to_owned(),
111 None => ".".to_owned(),
112 }
113}
114
115fn package_name(source: &str) -> Option<String> {
122 let bytes = source.as_bytes();
123 let mut at = 0;
124 while at < bytes.len() {
125 let rest = &source[at..];
126 if rest.starts_with("//") {
127 at += rest.find('\n').map_or(rest.len(), |end| end + 1);
128 } else if rest.starts_with("/*") {
129 at += rest.find("*/").map_or(rest.len(), |end| end + 2);
131 } else if bytes[at].is_ascii_whitespace() {
132 at += 1;
133 } else {
134 return rest
135 .strip_prefix("package")
136 .and_then(|rest| rest.strip_prefix(|c: char| c.is_ascii_whitespace()))
137 .and_then(|rest| rest.split_whitespace().next())
138 .map(str::to_owned);
139 }
140 }
141 None
142}
143
144fn read(path: &Path) -> Result<String, String> {
145 fs::read_to_string(path).map_err(|error| format!("{}: {error}", path.display()))
146}
147
148fn write(path: &Path, contents: &str) -> Result<(), String> {
149 if let Some(parent) = path.parent() {
150 fs::create_dir_all(parent).map_err(|error| format!("{}: {error}", parent.display()))?;
151 }
152 fs::write(path, contents).map_err(|error| format!("{}: {error}", path.display()))
153}
154
155fn evidence_name(directory: &str) -> String {
158 let slug = directory
159 .chars()
160 .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
161 .collect::<String>();
162 format!("{slug}.bin")
163}
164
165fn instrument_workspace(
168 workspace: &Path,
169 evidence_directory: &Path,
170) -> Result<InstrumentedWorkspace, String> {
171 let project = prepare_go_project(workspace)?;
172
173 let members = crate::go_project::workspace_modules(workspace);
177 let directories = if members.is_empty() {
178 vec![".".to_owned()]
179 } else {
180 members.into_iter().collect::<Vec<_>>()
181 };
182 let mut runtimes: Vec<(String, String)> = Vec::new();
183 for directory in &directories {
184 let at = workspace.join(directory);
185 let module = module_path(&at).ok_or_else(|| {
186 format!(
187 "{}: no module path in go.mod, so Supercov cannot name the package its runtime is imported from",
188 at.display()
189 )
190 })?;
191 write(
192 &at.join(RUNTIME_DIRECTORY).join("supercov.go"),
193 RUNTIME_SOURCE,
194 )?;
195 runtimes.push((directory.clone(), format!("{module}/{RUNTIME_DIRECTORY}")));
196 }
197 runtimes.sort_by_key(|(directory, _)| std::cmp::Reverse(directory.len()));
199 let import_for = |relative: &str| -> &str {
200 runtimes
201 .iter()
202 .find(|(directory, _)| {
203 directory == "."
204 || relative == directory
205 || relative.starts_with(&format!("{directory}/"))
206 })
207 .map(|(_, import)| import.as_str())
208 .unwrap_or_default()
209 };
210
211 let probe_count = project
215 .probes
216 .keys()
217 .max()
218 .map_or(0, |highest| *highest as usize + 1);
219
220 let mut source_packages: BTreeMap<String, String> = BTreeMap::new();
221 for (relative, instrumented) in &project.instrumented {
222 let instrumented = instrumented.replace(RUNTIME_IMPORT, import_for(relative));
223 let path = workspace.join(relative);
224 if let Some(name) = package_name(&instrumented) {
225 source_packages
226 .entry(directory_of(relative))
227 .or_insert(name);
228 }
229 write(&path, &instrumented)?;
230 }
231 for (directory, package) in &source_packages {
232 write(
233 &workspace.join(directory).join(PROBE_FILE),
234 &probe_array_file(
235 package,
236 RUNTIME_ALIAS,
237 import_for(&format!("{directory}/x.go")),
238 probe_count,
239 ),
240 )?;
241 }
242
243 let mut by_directory: BTreeMap<String, Vec<&String>> = BTreeMap::new();
244 for relative in &project.files.tests {
245 by_directory
246 .entry(directory_of(relative))
247 .or_default()
248 .push(relative);
249 }
250
251 let mut packages = Vec::new();
252 for (directory, tests) in by_directory {
253 let evidence = evidence_directory.join(evidence_name(&directory));
254 let evidence_literal = evidence.to_string_lossy().replace('\\', "\\\\");
255 let mut declared_in = BTreeMap::new();
256 let mut declares_test_main = false;
257 let mut harness_package: Option<String> = None;
261 for relative in tests {
262 let path = workspace.join(relative);
263 let source = read(&path)?;
264 let Some(name) = package_name(&source) else {
265 continue;
266 };
267 let file = instrument_test_file(&source, RUNTIME_ALIAS, &evidence_literal)
268 .map_err(|error| format!("{relative}: {error}"))?;
269 declares_test_main |= file.declares_test_main;
270 for test in &file.tests {
271 declared_in.insert(test.clone(), relative.clone());
272 }
273 let internal = source_packages.get(&directory) == Some(&name);
274 if internal || harness_package.is_none() {
275 harness_package = Some(name);
276 }
277 if !file.edits.is_empty() {
278 write(
279 &path,
280 &rewrite(&source, &file.edits).replace(RUNTIME_IMPORT, import_for(relative)),
281 )?;
282 }
283 }
284 let Some(package) = harness_package else {
285 continue;
286 };
287 write(
288 &workspace.join(&directory).join(HARNESS_FILE),
289 &synthesized_harness(
290 &package,
291 RUNTIME_ALIAS,
292 import_for(&format!("{directory}/x.go")),
293 probe_count,
294 &project.decision_widths,
295 &evidence_literal,
296 declares_test_main,
297 ),
298 )?;
299 packages.push(GoTestPackage {
300 directory,
301 evidence,
302 declared_in,
303 });
304 }
305 Ok(InstrumentedWorkspace { project, packages })
306}
307
308fn command_with_fresh_results(command: &[String]) -> (Vec<String>, bool) {
310 if command
311 .iter()
312 .any(|argument| argument == "-count" || argument.starts_with("-count="))
313 {
314 return (command.to_vec(), false);
315 }
316 let mut updated = command.to_vec();
317 let position = updated
319 .iter()
320 .position(|argument| argument == "test")
321 .map_or(updated.len(), |index| index + 1);
322 updated.insert(position, "-count=1".into());
323 (updated, true)
324}
325
326pub fn current_go_integrity(
328 root: &Path,
329 command: &[String],
330) -> Result<crate::run_store::RunIntegrity, String> {
331 let root = canonicalize_simplified(root).map_err(|error| error.to_string())?;
332 let files = crate::go_project::discover_go_files(&root)?;
333 create_explicit_run_integrity(
334 &root,
335 &go_integrity_inputs(&files, command),
336 &FrontendIntegrityInputs::embedded_go(),
337 )
338 .map_err(|error| error.to_string())
339}
340
341pub fn run_direct_go(
342 request: &DirectGoRunRequest,
343 diagnostics: &mut dyn Write,
344) -> Result<DirectGoRunResult, String> {
345 if request.command.is_empty() {
346 return Err("test command must not be empty".into());
347 }
348 let total_started = Instant::now();
349 let initialization_started = Instant::now();
350 let root = canonicalize_simplified(&request.root)
351 .map_err(|error| format!("{}: {error}", request.root.display()))?;
352 let mut lock = ProjectLock::acquire(&root, &request.run_id, &request.started_at)
353 .map_err(|error| error.to_string())?;
354 let initialization_ms = elapsed_ms(initialization_started);
355 let work_directory = root.join(".supercov/work").join(&request.run_id);
356 let result = (|| {
357 let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
358 .map_err(|error| error.to_string())?;
359 if !recovered_runs.is_empty() {
360 writeln!(
361 diagnostics,
362 "[supercov] recovered abandoned run(s): {}",
363 recovered_runs.join(", ")
364 )
365 .map_err(|error| error.to_string())?;
366 }
367
368 let adapter_started = Instant::now();
369 let files = crate::go_project::discover_go_files(&root)?;
370 let integrity_inputs = go_integrity_inputs(&files, &request.command);
371 let assertion_inputs =
372 crate::assertion_inputs::capture(&root, "go", integrity_inputs.assertion_paths())?;
373 let integrity = create_explicit_run_integrity(
374 &root,
375 &integrity_inputs,
376 &FrontendIntegrityInputs::embedded_go(),
377 )
378 .map_err(|error| error.to_string())?;
379
380 let workspace_started = Instant::now();
381 recover_cached_workspace(&root, &lock).map_err(|error| error.to_string())?;
382 let workspace =
383 prepare_cached_workspace(&root, &lock, &[]).map_err(|error| error.to_string())?;
384 let evidence_directory = work_directory.join("go/evidence");
385 fs::create_dir_all(&evidence_directory).map_err(|error| error.to_string())?;
386 let instrumented = instrument_workspace(&workspace, &evidence_directory)?;
387 let workspace_preparation_ms = elapsed_ms(workspace_started);
388 let adapter_setup_ms = (elapsed_ms(adapter_started) - workspace_preparation_ms).max(0.0);
389 writeln!(
390 diagnostics,
391 "[supercov] detected Go; instrumenting {} source file(s) across {} test package(s) in isolated workspace {}",
392 instrumented.project.instrumented.len(),
393 instrumented.packages.len(),
394 workspace.display()
395 )
396 .map_err(|error| error.to_string())?;
397 for (file, reason) in &instrumented.project.unparseable {
398 writeln!(
399 diagnostics,
400 "[supercov] could not parse {file}: {reason}; it carries no obligations"
401 )
402 .map_err(|error| error.to_string())?;
403 }
404 if instrumented.packages.is_empty() {
405 return Err(
406 "no Go test packages were found, so a run would measure nothing: Supercov needs at least one _test.go file"
407 .into(),
408 );
409 }
410
411 let (command, forced_fresh) = command_with_fresh_results(&request.command);
412 if forced_fresh {
413 writeln!(
414 diagnostics,
415 "[supercov] added -count=1: `go test` caches passing packages, and a cached package does not run, so it would record no coverage"
416 )
417 .map_err(|error| error.to_string())?;
418 }
419 let test_started = Instant::now();
420 let plan = ExecutionPlan {
421 preparation: Vec::new(),
422 test: ExecutionPhase {
423 name: "test".into(),
424 kind: PhaseKind::Test,
425 command: CommandSpec {
426 program: command[0].clone().into(),
427 arguments: command[1..].iter().map(OsString::from).collect(),
428 cwd: workspace.clone(),
429 environment: None,
430 captured_output: None,
431 },
432 },
433 };
434 let options = SupervisionOptions::from_environment().map_err(|error| error.to_string())?;
435 let execution = execute_plan(&plan, options, diagnostics, |_, _| Ok(()))
436 .map_err(|error| error.to_string())?;
437 let test_command_ms = elapsed_ms(test_started);
438 if let Some(signal) = execution.interrupted_signal {
439 return Err(format!(
440 "the test command was interrupted by {signal:?}; no run was published"
441 ));
442 }
443 let exit_code = execution.exit_code;
444
445 let publication_started = Instant::now();
446 let mut parts = Vec::new();
447 let mut outcomes = Vec::new();
448 let mut silent = Vec::new();
449 for package in &instrumented.packages {
450 let Ok(bytes) = fs::read(&package.evidence) else {
451 silent.push(package.directory.clone());
452 continue;
453 };
454 let evidence = read_evidence(&bytes)
455 .map_err(|error| format!("{}: {error}", package.evidence.display()))?;
456 for test in &evidence.tests {
457 outcomes.push(OwnedTestOutcome {
458 name: test.name.clone(),
459 runner: test.runner.clone(),
460 package: package.directory.clone(),
461 file: package.declared_in.get(&test.name).cloned(),
462 status: test.status.clone(),
463 });
464 }
465 parts.push(evidence);
466 }
467 if !silent.is_empty() {
468 writeln!(
469 diagnostics,
470 "[supercov] {} test package(s) wrote no evidence and are absent from this run: {}",
471 silent.len(),
472 silent.join(", ")
473 )
474 .map_err(|error| error.to_string())?;
475 }
476 if outcomes.is_empty() {
477 return Err(format!(
478 "no Go test recorded evidence (the command exited {exit_code}); a run that measured nothing is not published"
479 ));
480 }
481 let evidence = merge_evidence(parts);
482 let run = build_frontend_run(OwnedRunInputs {
483 declaration: go_declaration(),
484 environment: "go",
485 manifest: &instrumented.project.manifest,
486 probes: &instrumented.project.probes,
487 evidence: &evidence,
488 outcomes: &outcomes,
489 run_id: &request.run_id,
490 generated_at: &request.started_at,
491 test_exit_code: exit_code,
492 coverage_model: go_coverage_model(),
493 })
494 .map_err(|error| error.to_string())?;
495 validate_frontend_report_request(&run.declaration, &run.request)
499 .map_err(|error| error.to_string())?;
500 let archive_path = work_directory.join("evidence.raw.gz");
501 let raw = write_archive(
502 crate::assertion_inputs::append(
503 run.archive_entries().map_err(|error| error.to_string())?,
504 &assertion_inputs,
505 )?,
506 &archive_path,
507 )
508 .map_err(|error| error.to_string())?;
509 let evidence_publication_ms = elapsed_ms(publication_started);
510
511 let timings = RunTimings {
512 initialization_ms,
513 workspace_preparation_ms,
514 adapter_setup_ms,
515 instrumented_build_ms: 0.0,
516 test_command_ms,
517 evidence_publication_ms,
518 };
519 let metadata = RunMetadata {
520 id: request.run_id.clone(),
521 started_at: request.started_at.clone(),
522 duration_ms: elapsed_ms(total_started),
523 command: request.command.clone(),
524 test_exit_code: Some(exit_code),
525 integrity,
526 raw_evidence: RawEvidenceMetadata {
527 schema_version: raw.schema_version,
528 format: raw.format.into(),
529 file: raw.file.into(),
530 files: raw.files,
531 uncompressed_bytes: raw.uncompressed_bytes,
532 compressed_bytes: raw.compressed_bytes,
533 },
534 isolated_build: Some(true),
535 instrumented_build_cache: None,
536 timings: Some(timings),
537 merged: None,
538 parents: None,
539 };
540 let run_directory =
541 publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
542 finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
543 Ok(DirectGoRunResult {
544 run_id: request.run_id.clone(),
545 run_directory,
546 exit_code,
547 tests: outcomes
548 .iter()
549 .map(|outcome| outcome.name.as_str())
550 .collect::<BTreeSet<_>>()
551 .len(),
552 source_files: instrumented.project.instrumented.len(),
553 packages: instrumented.packages.len(),
554 recovered_runs,
555 metadata,
556 })
557 })();
558 if result.is_err() {
559 let _ = remove_stored_tree_deferred(&root, &work_directory);
560 }
561 let release = lock.release().map_err(|error| error.to_string());
562 match (result, release) {
563 (Ok(result), Ok(())) => Ok(result),
564 (Err(error), _) => Err(error),
565 (Ok(_), Err(error)) => Err(error),
566 }
567}
568
569#[cfg(test)]
570mod tests {
571 use super::*;
572
573 #[test]
574 fn the_package_clause_is_found_past_whatever_precedes_it() {
575 assert_eq!(package_name("package main\n").as_deref(), Some("main"));
579 assert_eq!(
580 package_name("//go:build linux\n\n// Copyright.\npackage worker\n").as_deref(),
581 Some("worker")
582 );
583 assert_eq!(
584 package_name("/*\nA package comment\nspanning lines.\n*/\npackage doc\n").as_deref(),
585 Some("doc")
586 );
587 assert_eq!(
588 package_name("/* one line */ package inline\n").as_deref(),
589 Some("inline")
590 );
591 assert_eq!(
593 package_name("package api // the public surface\n").as_deref(),
594 Some("api")
595 );
596 assert_eq!(package_name("import \"fmt\"\n"), None);
597 }
598
599 #[test]
600 fn caching_is_disabled_unless_the_author_already_chose() {
601 let (command, forced) =
605 command_with_fresh_results(&["go".into(), "test".into(), "./...".into()]);
606 assert!(forced);
607 assert_eq!(command, ["go", "test", "-count=1", "./..."]);
608
609 let (command, forced) =
611 command_with_fresh_results(&["go".into(), "test".into(), "-count=3".into()]);
612 assert!(!forced);
613 assert_eq!(command, ["go", "test", "-count=3"]);
614 }
615
616 #[test]
617 fn each_package_gets_an_evidence_file_of_its_own() {
618 assert_ne!(evidence_name("internal/auth"), evidence_name("internal/db"));
621 assert_eq!(evidence_name("."), "_.bin");
622 assert!(!evidence_name("internal/auth").contains('/'));
623 }
624}