use std::collections::{HashMap, VecDeque};
#[allow(clippy::disallowed_types)]
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use anyhow::{Result, anyhow, bail};
use oxdock_sys_test_utils::exit_status_from_code;
use crate::{
BackgroundHandle, CommandContext, CommandMode, CommandOptions, CommandResult, CommandStderr,
CommandStdin, CommandStdout, ProcessManager,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MockStreamMode {
Inherit,
Stream,
}
fn stderr_mode(stderr: &CommandStderr) -> MockStreamMode {
match stderr {
CommandStderr::Inherit => MockStreamMode::Inherit,
CommandStderr::Stream(_) => MockStreamMode::Stream,
#[cfg(not(miri))]
CommandStderr::OsPipe(_) => MockStreamMode::Stream,
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[allow(clippy::disallowed_types)]
pub struct MockRunCall {
pub script: String,
pub cwd: PathBuf,
pub envs: HashMap<String, String>,
pub cargo_target_dir: PathBuf,
pub stdin_provided: bool,
pub stdin: Option<Vec<u8>>,
pub stderr_mode: MockStreamMode,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[allow(clippy::disallowed_types)]
pub struct MockSpawnCall {
pub script: String,
pub cwd: PathBuf,
pub envs: HashMap<String, String>,
pub cargo_target_dir: PathBuf,
pub stdin_provided: bool,
pub stdin: Option<Vec<u8>>,
pub stderr_mode: MockStreamMode,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[allow(clippy::disallowed_types)]
pub struct MockRunArgvCall {
pub argv: Vec<String>,
pub cwd: PathBuf,
pub envs: HashMap<String, String>,
pub cargo_target_dir: PathBuf,
pub stdin_provided: bool,
pub stdin: Option<Vec<u8>>,
pub stderr_mode: MockStreamMode,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[allow(clippy::disallowed_types)]
pub struct MockSpawnArgvCall {
pub argv: Vec<String>,
pub cwd: PathBuf,
pub envs: HashMap<String, String>,
pub cargo_target_dir: PathBuf,
pub stdin_provided: bool,
pub stdin: Option<Vec<u8>>,
pub stderr_mode: MockStreamMode,
}
#[derive(Clone, Default)]
pub struct MockProcessManager {
runs: Arc<Mutex<Vec<MockRunCall>>>,
spawns: Arc<Mutex<Vec<MockSpawnCall>>>,
argv_runs: Arc<Mutex<Vec<MockRunArgvCall>>>,
argv_spawns: Arc<Mutex<Vec<MockSpawnArgvCall>>>,
killed: Arc<Mutex<Vec<String>>>,
plans: Arc<Mutex<VecDeque<BgPlan>>>,
}
impl MockProcessManager {
pub fn recorded_runs(&self) -> Vec<MockRunCall> {
self.runs.lock().expect("mock state poisoned").clone()
}
pub fn spawn_log(&self) -> Vec<MockSpawnCall> {
self.spawns.lock().expect("mock state poisoned").clone()
}
pub fn recorded_argv_runs(&self) -> Vec<MockRunArgvCall> {
self.argv_runs.lock().expect("mock state poisoned").clone()
}
pub fn argv_spawn_log(&self) -> Vec<MockSpawnArgvCall> {
self.argv_spawns
.lock()
.expect("mock state poisoned")
.clone()
}
pub fn killed(&self) -> Vec<String> {
self.killed.lock().expect("mock state poisoned").clone()
}
pub fn push_bg_plan(&self, ready_after: usize, status: std::process::ExitStatus) {
self.plans
.lock()
.expect("mock state poisoned")
.push_back(BgPlan {
ready_after,
status,
});
}
}
impl ProcessManager for MockProcessManager {
type Handle = MockHandle;
fn run_command(
&mut self,
ctx: &CommandContext,
script: &str,
options: CommandOptions,
) -> Result<CommandResult<Self::Handle>> {
let CommandOptions {
mode,
stdin,
stdout,
stderr,
} = options;
let stdin_provided = match &stdin {
CommandStdin::Stream(_) => true,
#[cfg(not(miri))]
CommandStdin::OsPipe(_) => true,
_ => false,
};
let captured_stdin = capture_stdin(stdin)?;
let recorded_stderr = stderr_mode(&stderr);
match mode {
CommandMode::Foreground => {
self.runs
.lock()
.expect("mock state poisoned")
.push(MockRunCall {
script: script.to_string(),
cwd: ctx.cwd().to_path_buf(),
envs: (**ctx.envs()).clone(),
cargo_target_dir: ctx.cargo_target_dir().to_path_buf(),
stdin_provided,
stdin: captured_stdin.clone(),
stderr_mode: recorded_stderr,
});
match stdout {
CommandStdout::Capture => Ok(CommandResult::Captured(Vec::new())),
CommandStdout::Stream(_) | CommandStdout::Inherit => {
Ok(CommandResult::Completed)
}
#[cfg(not(miri))]
CommandStdout::OsPipe(_) => Ok(CommandResult::Completed),
}
}
CommandMode::Background => {
if matches!(stdout, CommandStdout::Capture) {
bail!("cannot capture stdout for background command");
}
self.spawns
.lock()
.expect("mock state poisoned")
.push(MockSpawnCall {
script: script.to_string(),
cwd: ctx.cwd().to_path_buf(),
envs: (**ctx.envs()).clone(),
cargo_target_dir: ctx.cargo_target_dir().to_path_buf(),
stdin_provided,
stdin: captured_stdin.clone(),
stderr_mode: recorded_stderr,
});
let plan = self
.plans
.lock()
.expect("mock state poisoned")
.pop_front()
.unwrap_or_else(BgPlan::success);
Ok(CommandResult::Background(MockHandle {
script: script.to_string(),
remaining: plan.ready_after,
status: plan.status,
killed: Arc::clone(&self.killed),
reaped: false,
}))
}
}
}
fn run_argv(
&mut self,
ctx: &CommandContext,
argv: &[String],
options: CommandOptions,
) -> Result<CommandResult<Self::Handle>> {
let CommandOptions {
mode,
stdin,
stdout,
stderr,
} = options;
let stdin_provided = match &stdin {
CommandStdin::Stream(_) => true,
#[cfg(not(miri))]
CommandStdin::OsPipe(_) => true,
_ => false,
};
let captured_stdin = capture_stdin(stdin)?;
let recorded_stderr = stderr_mode(&stderr);
let label = argv.join(" ");
match mode {
CommandMode::Foreground => {
self.argv_runs
.lock()
.expect("mock state poisoned")
.push(MockRunArgvCall {
argv: argv.to_vec(),
cwd: ctx.cwd().to_path_buf(),
envs: (**ctx.envs()).clone(),
cargo_target_dir: ctx.cargo_target_dir().to_path_buf(),
stdin_provided,
stdin: captured_stdin.clone(),
stderr_mode: recorded_stderr,
});
match stdout {
CommandStdout::Capture => Ok(CommandResult::Captured(Vec::new())),
CommandStdout::Stream(_) | CommandStdout::Inherit => {
Ok(CommandResult::Completed)
}
#[cfg(not(miri))]
CommandStdout::OsPipe(_) => Ok(CommandResult::Completed),
}
}
CommandMode::Background => {
if matches!(stdout, CommandStdout::Capture) {
bail!("cannot capture stdout for background command");
}
self.argv_spawns
.lock()
.expect("mock state poisoned")
.push(MockSpawnArgvCall {
argv: argv.to_vec(),
cwd: ctx.cwd().to_path_buf(),
envs: (**ctx.envs()).clone(),
cargo_target_dir: ctx.cargo_target_dir().to_path_buf(),
stdin_provided,
stdin: captured_stdin.clone(),
stderr_mode: recorded_stderr,
});
let plan = self
.plans
.lock()
.expect("mock state poisoned")
.pop_front()
.unwrap_or_else(BgPlan::success);
Ok(CommandResult::Background(MockHandle {
script: label,
remaining: plan.ready_after,
status: plan.status,
killed: Arc::clone(&self.killed),
reaped: false,
}))
}
}
}
}
struct BgPlan {
ready_after: usize,
status: std::process::ExitStatus,
}
impl BgPlan {
fn success() -> Self {
Self {
ready_after: 0,
status: exit_status_from_code(0),
}
}
}
#[derive(Clone)]
pub struct MockHandle {
script: String,
remaining: usize,
status: std::process::ExitStatus,
killed: Arc<Mutex<Vec<String>>>,
reaped: bool,
}
impl BackgroundHandle for MockHandle {
fn try_wait(&mut self) -> Result<Option<std::process::ExitStatus>> {
if self.remaining == 0 {
self.reaped = true;
Ok(Some(self.status))
} else {
self.remaining -= 1;
Ok(None)
}
}
fn kill(&mut self) -> Result<()> {
self.reaped = true;
self.killed
.lock()
.expect("mock state poisoned")
.push(self.script.clone());
Ok(())
}
fn wait(&mut self) -> Result<std::process::ExitStatus> {
self.reaped = true;
Ok(self.status)
}
}
impl Drop for MockHandle {
fn drop(&mut self) {
if !self.reaped {
self.killed
.lock()
.expect("mock state poisoned")
.push(self.script.clone());
}
}
}
fn capture_stdin(stdin: CommandStdin) -> Result<Option<Vec<u8>>> {
match stdin {
CommandStdin::Stream(reader) => {
let mut guard = reader.lock().map_err(|_| anyhow!("failed to lock stdin"))?;
let mut buf = Vec::new();
std::io::copy(&mut *guard, &mut buf)?;
Ok(Some(buf))
}
#[cfg(not(miri))]
CommandStdin::OsPipe(_) => Ok(None),
CommandStdin::Null | CommandStdin::Inherit => Ok(None),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::SharedInput;
use oxdock_fs::{GuardedPath, PolicyPath};
use std::collections::HashMap;
fn test_ctx() -> (GuardedPath, CommandContext) {
let temp = GuardedPath::tempdir().expect("tempdir");
let guard = temp.as_guarded_path().clone();
let cwd: PolicyPath = guard.clone().into();
let scratch = oxdock_fs::reserve_cargo_scratch().expect("scratch");
let ctx = CommandContext::from_map(&cwd, &HashMap::new(), &scratch, &guard, &guard);
(guard, ctx)
}
fn ok_status() -> std::process::ExitStatus {
exit_status_from_code(0)
}
fn failing_status() -> std::process::ExitStatus {
exit_status_from_code(3)
}
#[test]
fn foreground_run_records_invocation_and_stderr_mode() {
let (_root, ctx) = test_ctx();
let mut pm = MockProcessManager::default();
let input: SharedInput =
std::sync::Arc::new(std::sync::Mutex::new(std::io::Cursor::new(b"abc".to_vec())));
let stderr_sink: crate::SharedOutput =
std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
let options = CommandOptions {
stdin: CommandStdin::Stream(input),
stderr: CommandStderr::Stream(stderr_sink),
..Default::default()
};
pm.run_command(&ctx, "build-all", options).expect("run");
let runs = pm.recorded_runs();
assert_eq!(runs.len(), 1);
let call = &runs[0];
assert_eq!(call.script, "build-all");
assert_eq!(call.cwd, ctx.cwd().to_path_buf());
assert!(call.stdin_provided);
assert_eq!(call.stdin.as_deref(), Some(b"abc".as_slice()));
assert_eq!(call.stderr_mode, MockStreamMode::Stream);
}
#[test]
fn background_spawn_records_stderr_inherit_mode_by_default() {
let (_root, ctx) = test_ctx();
let mut pm = MockProcessManager::default();
let options = CommandOptions {
mode: CommandMode::Background,
..Default::default()
};
match pm.run_command(&ctx, "serve", options).expect("spawn") {
CommandResult::Background(_) => {}
_ => panic!("expected background handle"),
}
let spawns = pm.spawn_log();
assert_eq!(spawns.len(), 1);
assert_eq!(spawns[0].script, "serve");
assert!(!spawns[0].stdin_provided);
assert_eq!(spawns[0].stderr_mode, MockStreamMode::Inherit);
}
#[test]
fn background_capture_stdout_bails() {
let (_root, ctx) = test_ctx();
let mut pm = MockProcessManager::default();
let options = CommandOptions {
mode: CommandMode::Background,
stdout: CommandStdout::Capture,
..Default::default()
};
match pm.run_command(&ctx, "x", options) {
Err(err) => assert!(err.to_string().contains("cannot capture stdout")),
_ => panic!("expected bail for background capture"),
}
}
#[test]
fn try_wait_counts_down_until_planned_readiness() {
let (_root, ctx) = test_ctx();
let mut pm = MockProcessManager::default();
pm.push_bg_plan(2, failing_status());
let options = CommandOptions {
mode: CommandMode::Background,
..Default::default()
};
let mut handle = match pm.run_command(&ctx, "job", options).expect("spawn") {
CommandResult::Background(handle) => handle,
_ => panic!("expected background handle"),
};
assert!(handle.try_wait().expect("poll 1").is_none());
assert!(handle.try_wait().expect("poll 2").is_none());
assert_eq!(handle.try_wait().expect("poll 3"), Some(failing_status()));
}
#[test]
fn wait_returns_planned_status_even_before_countdown_finishes() {
let (_root, ctx) = test_ctx();
let mut pm = MockProcessManager::default();
pm.push_bg_plan(usize::MAX, failing_status());
let options = CommandOptions {
mode: CommandMode::Background,
..Default::default()
};
let mut handle = match pm.run_command(&ctx, "stuck", options).expect("spawn") {
CommandResult::Background(handle) => handle,
_ => panic!("expected background handle"),
};
assert!(handle.try_wait().expect("poll").is_none());
assert_eq!(handle.wait().expect("wait"), failing_status());
}
#[test]
fn missing_plan_defaults_to_immediate_success() {
let (_root, ctx) = test_ctx();
let mut pm = MockProcessManager::default();
let options = CommandOptions {
mode: CommandMode::Background,
..Default::default()
};
let mut handle = match pm.run_command(&ctx, "quick", options).expect("spawn") {
CommandResult::Background(handle) => handle,
_ => panic!("expected background handle"),
};
assert_eq!(handle.try_wait().expect("poll"), Some(ok_status()));
}
#[test]
fn kill_logs_scripts_in_kill_order() {
let (_root, ctx) = test_ctx();
let mut pm = MockProcessManager::default();
let options = CommandOptions {
mode: CommandMode::Background,
..Default::default()
};
let mut a = match pm.run_command(&ctx, "a", options.clone()).expect("spawn") {
CommandResult::Background(handle) => handle,
_ => panic!("expected background handle"),
};
let mut b = match pm.run_command(&ctx, "b", options).expect("spawn") {
CommandResult::Background(handle) => handle,
_ => panic!("expected background handle"),
};
b.kill().expect("kill b");
a.kill().expect("kill a");
assert_eq!(pm.killed(), vec!["b".to_string(), "a".to_string()]);
}
#[test]
fn capture_stdin_reads_stream_or_reports_none() {
let none = capture_stdin(CommandStdin::Null).expect("none case");
assert_eq!(none, None);
let input: SharedInput =
std::sync::Arc::new(std::sync::Mutex::new(std::io::Cursor::new(b"xyz".to_vec())));
let some = capture_stdin(CommandStdin::Stream(input)).expect("some case");
assert_eq!(some, Some(b"xyz".to_vec()));
}
}