1use std::{
13 ffi::{OsStr, OsString},
14 fs,
15 io::Write,
16 path::{Path, PathBuf},
17 process::{Command, Stdio},
18};
19
20use serde_json::Value;
21
22pub const DOCKER_COMMAND: &str = "__docker";
24pub const STATE_VARIABLE: &str = "SUPERCOV_DOCKER_STATE";
26pub const MOUNT_VARIABLE: &str = "SUPERCOV_DOCKER_MOUNT";
28pub const COPY_VARIABLE: &str = "SUPERCOV_DOCKER_COPY";
30pub const ENVIRONMENT_VARIABLE: &str = "SUPERCOV_DOCKER_ENVIRONMENT";
32pub const CONTAINER_VARIABLE: &str = "SUPERCOV_CONTAINER";
34
35const VALUED_OPTIONS: &[&str] = &[
37 "-f",
38 "--file",
39 "-p",
40 "--project-name",
41 "--profile",
42 "--project-directory",
43 "--env-file",
44 "--ansi",
45 "--progress",
46 "--parallel",
47];
48
49const CREATING: &[&str] = &["up", "run", "create"];
52
53#[derive(Debug, PartialEq)]
56struct ComposeCommand {
57 docker_options: Vec<OsString>,
58 compose_options: Vec<OsString>,
59 subcommand: OsString,
60 rest: Vec<OsString>,
61}
62
63fn parse(arguments: &[OsString], compose_binary: bool) -> Option<ComposeCommand> {
66 let mut index = 0;
67 let mut docker_options = Vec::new();
68 if !compose_binary {
69 while index < arguments.len() {
71 let argument = arguments[index].to_str()?;
72 if argument == "compose" {
73 index += 1;
74 break;
75 }
76 if !argument.starts_with('-') {
77 return None;
78 }
79 docker_options.push(arguments[index].clone());
80 if matches!(
81 argument,
82 "--context" | "-c" | "-H" | "--host" | "--config" | "-l" | "--log-level"
83 ) {
84 index += 1;
85 docker_options.push(arguments.get(index)?.clone());
86 }
87 index += 1;
88 }
89 if index == 0 || arguments.get(index - 1).and_then(|word| word.to_str()) != Some("compose")
90 {
91 return None;
92 }
93 }
94 let mut compose_options = Vec::new();
95 while index < arguments.len() {
96 let argument = arguments[index].to_str()?;
97 if !argument.starts_with('-') {
98 return Some(ComposeCommand {
99 docker_options,
100 compose_options,
101 subcommand: arguments[index].clone(),
102 rest: arguments[index + 1..].to_vec(),
103 });
104 }
105 compose_options.push(arguments[index].clone());
106 if VALUED_OPTIONS.contains(&argument) {
107 index += 1;
108 compose_options.push(arguments.get(index)?.clone());
109 }
110 index += 1;
111 }
112 None
113}
114
115fn instrument(
120 mut config: Value,
121 copy: &Path,
122 mount: &Path,
123 environment: &[(String, String)],
124) -> Option<(String, Value, Vec<String>)> {
125 let name = config.get("name")?.as_str()?.to_owned();
126 let services = config.get_mut("services")?.as_object_mut()?;
127 let mut changed = Vec::new();
128 for (service_name, service) in services.iter_mut() {
129 let Some(context) = service
130 .get("build")
131 .and_then(|build| build.get("context"))
132 .and_then(Value::as_str)
133 else {
134 continue;
135 };
136 if !Path::new(context).starts_with(copy) {
137 continue;
138 }
139 let service = service.as_object_mut()?;
140 let variables = service
141 .entry("environment")
142 .or_insert_with(|| Value::Object(Default::default()));
143 let variables = variables.as_object_mut()?;
144 for (key, value) in environment {
145 variables.insert(key.clone(), Value::String(value.clone()));
146 }
147 variables.insert(CONTAINER_VARIABLE.into(), Value::String("1".into()));
150 variables
154 .entry("PYTHONPYCACHEPREFIX")
155 .or_insert_with(|| Value::String("/tmp/supercov-pycache".into()));
156 let volumes = service
157 .entry("volumes")
158 .or_insert_with(|| Value::Array(Vec::new()));
159 volumes.as_array_mut()?.push(serde_json::json!({
160 "type": "bind",
161 "source": mount.display().to_string(),
162 "target": mount.display().to_string(),
163 }));
164 changed.push(service_name.clone());
165 }
166 (!changed.is_empty()).then_some((name, config, changed))
167}
168
169fn joined_with_image(name: &str, ours: &str, image: Option<&str>) -> String {
173 let (runtime, separator) = match name {
174 "PYTHONPATH" => (ours.split(':').next().unwrap_or(ours).to_owned(), ":"),
176 _ => (
178 ours.split_whitespace()
179 .filter(|option| option.contains("supercov"))
180 .collect::<Vec<_>>()
181 .join(" "),
182 " ",
183 ),
184 };
185 match image.filter(|value| !value.is_empty()) {
186 Some(image) => format!("{runtime}{separator}{image}"),
187 None => runtime,
188 }
189}
190
191fn image_variable(
193 docker: &Path,
194 docker_options: &[OsString],
195 image: &str,
196 name: &str,
197) -> Option<String> {
198 let output = Command::new(docker)
199 .args(docker_options)
200 .args([
201 "image",
202 "inspect",
203 "--format",
204 "{{json .Config.Env}}",
205 image,
206 ])
207 .stdin(Stdio::null())
208 .stderr(Stdio::null())
209 .output()
210 .ok()?;
211 serde_json::from_slice::<Vec<String>>(&output.stdout)
212 .ok()?
213 .into_iter()
214 .find_map(|entry| entry.strip_prefix(&format!("{name}=")).map(str::to_owned))
215}
216
217pub fn run(docker: &Path, arguments: &[OsString]) -> i32 {
220 let compose_binary = docker
221 .file_stem()
222 .is_some_and(|stem| stem == OsStr::new("docker-compose"));
223 let rewritten = parse(arguments, compose_binary)
224 .filter(|command| {
225 command
226 .subcommand
227 .to_str()
228 .is_some_and(|subcommand| CREATING.contains(&subcommand))
229 })
230 .and_then(|command| rewrite(docker, compose_binary, &command));
231 run_docker(docker, rewritten.as_deref().unwrap_or(arguments))
232}
233
234fn rewrite(docker: &Path, compose_binary: bool, command: &ComposeCommand) -> Option<Vec<OsString>> {
237 let state = PathBuf::from(std::env::var_os(STATE_VARIABLE)?);
238 let mount = PathBuf::from(std::env::var_os(MOUNT_VARIABLE)?);
239 let copy = PathBuf::from(std::env::var_os(COPY_VARIABLE)?);
240 let environment = std::env::var(ENVIRONMENT_VARIABLE)
241 .ok()?
242 .split(',')
243 .filter(|name| !name.is_empty())
244 .filter_map(|name| Some((name.to_owned(), std::env::var(name).ok()?)))
245 .collect::<Vec<_>>();
246 let prefix = |options: &[OsString]| {
247 let mut line = Vec::new();
248 if !compose_binary {
249 line.extend(command.docker_options.iter().cloned());
250 line.push(OsString::from("compose"));
251 }
252 line.extend(options.iter().cloned());
253 line
254 };
255 let mut config_line = prefix(&command.compose_options);
256 config_line.extend(["config", "--format", "json"].map(OsString::from));
257 let output = Command::new(docker)
258 .args(&config_line)
259 .stdin(Stdio::null())
260 .stderr(Stdio::null())
261 .output()
262 .ok()?;
263 if !output.status.success() {
264 return None;
265 }
266 let config = serde_json::from_slice::<Value>(&output.stdout).ok()?;
267 let (name, mut config, services) = instrument(config, ©, &mount, &environment)?;
268 fs::create_dir_all(&state).ok()?;
269 let file = state.join(format!("{}.compose.json", sanitized(&name)));
270 fs::write(&file, serde_json::to_vec_pretty(&config).ok()?).ok()?;
271 let mut options = vec![
274 OsString::from("-f"),
275 file.clone().into_os_string(),
276 OsString::from("-p"),
277 OsString::from(&name),
278 ];
279 let mut kept = command.compose_options.iter();
280 while let Some(option) = kept.next() {
281 match option.to_str() {
282 Some("-f" | "--file" | "-p" | "--project-name") => {
283 kept.next();
284 }
285 Some("--project-directory" | "--env-file" | "--profile") => {
286 options.push(option.clone());
287 options.extend(kept.next().cloned());
288 }
289 _ => options.push(option.clone()),
290 }
291 }
292 let mut build = prefix(&options);
296 build.push(OsString::from("build"));
297 build.extend(services.iter().map(OsString::from));
298 let _ = Command::new(docker)
299 .args(&build)
300 .stdin(Stdio::null())
301 .status();
302 let mut rewritten = false;
304 for service in &services {
305 let image = config["services"][service]["image"]
306 .as_str()
307 .map_or_else(|| format!("{name}-{service}"), str::to_owned);
308 for variable in ["PYTHONPATH", "RUBYOPT"] {
309 let Some(ours) = config["services"][service]["environment"][variable].as_str() else {
310 continue;
311 };
312 let value = joined_with_image(
313 variable,
314 ours,
315 image_variable(docker, &command.docker_options, &image, variable).as_deref(),
316 );
317 config["services"][service]["environment"][variable] = Value::String(value);
318 rewritten = true;
319 }
320 }
321 if rewritten {
322 fs::write(&file, serde_json::to_vec_pretty(&config).ok()?).ok()?;
323 }
324 let mut record = fs::OpenOptions::new()
325 .create(true)
326 .append(true)
327 .open(state.join("projects.jsonl"))
328 .ok()?;
329 let line = serde_json::json!({
330 "docker": docker.display().to_string(),
331 "composeBinary": compose_binary,
332 "dockerOptions": command.docker_options.iter().map(|option| option.to_string_lossy()).collect::<Vec<_>>(),
333 "file": file.display().to_string(),
334 "project": name,
335 });
336 writeln!(record, "{line}").ok()?;
337 let mut line = prefix(&options);
338 line.push(command.subcommand.clone());
339 line.extend(command.rest.iter().cloned());
340 Some(line)
341}
342
343fn sanitized(name: &str) -> String {
344 name.chars()
345 .map(|c| {
346 if c.is_ascii_alphanumeric() || c == '-' {
347 c
348 } else {
349 '_'
350 }
351 })
352 .collect()
353}
354
355#[cfg(unix)]
356fn run_docker(docker: &Path, arguments: &[OsString]) -> i32 {
357 use std::os::unix::process::CommandExt;
358 let error = Command::new(docker).args(arguments).exec();
361 eprintln!("[supercov] could not run {}: {error}", docker.display());
362 127
363}
364
365#[cfg(not(unix))]
366fn run_docker(docker: &Path, arguments: &[OsString]) -> i32 {
367 Command::new(docker)
368 .args(arguments)
369 .status()
370 .map_or(127, |status| status.code().unwrap_or(1))
371}
372
373pub fn stop_started(state: &Path) {
377 let Ok(text) = fs::read_to_string(state.join("projects.jsonl")) else {
378 return;
379 };
380 let mut stopped = std::collections::BTreeSet::new();
381 for line in text.lines() {
382 let Ok(project) = serde_json::from_str::<Value>(line) else {
383 continue;
384 };
385 let (Some(docker), Some(file), Some(name)) = (
386 project["docker"].as_str(),
387 project["file"].as_str(),
388 project["project"].as_str(),
389 ) else {
390 continue;
391 };
392 if !stopped.insert(name.to_owned()) {
393 continue;
394 }
395 let mut line = Vec::<String>::new();
396 if project["composeBinary"].as_bool() != Some(true) {
397 line.extend(
398 project["dockerOptions"]
399 .as_array()
400 .into_iter()
401 .flatten()
402 .filter_map(|option| option.as_str().map(str::to_owned)),
403 );
404 line.push("compose".into());
405 }
406 line.extend(["-f", file, "-p", name, "stop", "-t", "10"].map(str::to_owned));
407 let _ = Command::new(docker)
408 .args(&line)
409 .stdin(Stdio::null())
410 .stdout(Stdio::null())
411 .stderr(Stdio::null())
412 .status();
413 }
414}
415
416pub fn write_launchers(directory: &Path, path: &OsStr) -> Option<()> {
419 let binary = crate::source_tools::launching_binary()?;
420 let mut found = false;
421 for name in ["docker", "docker-compose"] {
422 let Some(real) = std::env::split_paths(path)
423 .map(|entry| entry.join(name))
424 .find(|candidate| candidate.is_file())
425 else {
426 continue;
427 };
428 if real.starts_with(directory) {
430 continue;
431 }
432 fs::create_dir_all(directory).ok()?;
433 let quoted = |path: &Path| format!("'{}'", path.to_string_lossy().replace('\'', "'\\''"));
434 crate::source_tools::write_launcher(
435 &directory.join(name),
436 &format!(
437 "#!/bin/sh\n# Gives a server compose builds from Supercov's copy what its coverage needs; see `supercov --help`.\nexec {} {DOCKER_COMMAND} {} \"$@\"\n",
438 quoted(&binary),
439 quoted(&real),
440 ),
441 )
442 .ok()?;
443 found = true;
444 }
445 found.then_some(())
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451
452 fn words(line: &str) -> Vec<OsString> {
453 line.split_whitespace().map(OsString::from).collect()
454 }
455
456 #[test]
457 fn a_compose_command_is_split_where_compose_reads_it() {
458 let command = parse(
459 &words("--context ci compose -f a.yml -p demo --profile e2e up -d web"),
460 false,
461 )
462 .unwrap();
463 assert_eq!(command.docker_options, words("--context ci"));
464 assert_eq!(
465 command.compose_options,
466 words("-f a.yml -p demo --profile e2e")
467 );
468 assert_eq!(command.subcommand, "up");
469 assert_eq!(command.rest, words("-d web"));
470 let binary = parse(&words("up --build"), true).unwrap();
471 assert!(binary.docker_options.is_empty() && binary.compose_options.is_empty());
472 assert_eq!(binary.subcommand, "up");
473 assert!(parse(&words("run --rm alpine true"), false).is_none());
474 assert!(parse(&words("build ."), false).is_none());
475 }
476
477 #[test]
478 fn only_services_built_from_the_copy_are_given_the_runtime() {
479 let config = serde_json::json!({
480 "name": "shop",
481 "services": {
482 "api": { "build": { "context": "/work/.supercov/workspaces/workspace/shop/api" }, "environment": { "PORT": "8080" } },
483 "db": { "image": "postgres:17" },
484 "other": { "build": { "context": "/elsewhere" } },
485 }
486 });
487 let (name, config, services) = instrument(
488 config,
489 Path::new("/work/.supercov/workspaces/workspace/shop"),
490 Path::new("/work/.supercov"),
491 &[(
492 "SUPERCOV_GO_SERVER_EVIDENCE_DIR".into(),
493 "/work/.supercov/work/run/go/evidence".into(),
494 )],
495 )
496 .unwrap();
497 assert_eq!(name, "shop");
498 assert_eq!(services, ["api"]);
499 assert_eq!(
500 config["services"]["api"]["environment"]["SUPERCOV_CONTAINER"],
501 "1"
502 );
503 let api = &config["services"]["api"];
504 assert_eq!(api["environment"]["PORT"], "8080");
505 assert_eq!(
506 api["environment"]["SUPERCOV_GO_SERVER_EVIDENCE_DIR"],
507 "/work/.supercov/work/run/go/evidence"
508 );
509 assert_eq!(api["volumes"][0]["source"], "/work/.supercov");
510 assert_eq!(api["volumes"][0]["target"], "/work/.supercov");
511 assert!(config["services"]["db"].get("volumes").is_none());
512 assert!(config["services"]["other"].get("environment").is_none());
513 assert_eq!(
515 joined_with_image("PYTHONPATH", "/r/runtime:/host/lib", Some("/app")),
516 "/r/runtime:/app"
517 );
518 assert_eq!(
519 joined_with_image("PYTHONPATH", "/r/runtime", None),
520 "/r/runtime"
521 );
522 assert_eq!(
523 joined_with_image(
524 "RUBYOPT",
525 "-r/r/supercov_runtime.rb -W0",
526 Some("-rbundler/setup")
527 ),
528 "-r/r/supercov_runtime.rb -rbundler/setup"
529 );
530 assert!(
532 instrument(
533 serde_json::json!({ "name": "x", "services": { "db": { "image": "postgres" } } }),
534 Path::new("/copy"),
535 Path::new("/m"),
536 &[]
537 )
538 .is_none()
539 );
540 }
541}