use crate::utils::output_files::create_output_file_stream;
use std::{
io::Read,
path::PathBuf,
process::{Command, Stdio},
};
pub fn shell_command(command: &str) -> Command {
#[cfg(windows)]
{
let mut cmd = Command::new("cmd");
cmd.args(["/C", command]);
cmd
}
#[cfg(not(windows))]
{
let mut cmd = Command::new("sh");
cmd.args(["-c", command]);
cmd
}
}
pub fn shell_tokenize(input: &str) -> Vec<String> {
let mut out = Vec::new();
let mut current = String::new();
let mut quote: Option<char> = None;
for ch in input.chars() {
match quote {
Some(q) => {
if ch == q {
quote = None;
} else {
current.push(ch);
}
}
None => match ch {
'\'' | '"' => quote = Some(ch),
c if c.is_whitespace() => {
if !current.is_empty() {
out.push(std::mem::take(&mut current));
}
}
c => current.push(c),
},
}
}
if !current.is_empty() {
out.push(current);
}
out
}
pub fn shell_quote(value: &str) -> String {
if value.is_empty() {
return "''".to_string();
}
let escaped = value.replace('\'', "'\\''");
format!("'{escaped}'")
}
pub fn wrap_cd(command: &str, cwd: &str) -> String {
let trimmed = cwd.trim();
if trimmed.is_empty() || trimmed == "~" {
return command.to_string();
}
format!(
"cd {} 2>/dev/null || exit 1; {}",
shell_dir(trimmed),
command
)
}
fn shell_dir(dir: &str) -> String {
let Some(rest) = dir.strip_prefix('~') else {
return shell_quote(dir);
};
let (home, tail) = match rest.split_once('/') {
Some((home, tail)) => (home, tail),
None => (rest, ""),
};
let mut out = format!("~{home}");
if !tail.is_empty() {
out.push('/');
out.push_str(&shell_quote(tail));
}
out
}
pub fn drain_tail(mut reader: impl Read, limit: usize) -> (String, bool) {
let mut buf: Vec<u8> = Vec::new();
let mut chunk = [0u8; 8192];
let mut truncated = false;
loop {
match reader.read(&mut chunk) {
Ok(0) => break,
Ok(n) => {
buf.extend_from_slice(&chunk[..n]);
if buf.len() > limit * 2 {
let drop = buf.len() - limit;
buf.drain(..drop);
truncated = true;
}
}
Err(_) => break,
}
}
if buf.len() > limit {
let drop = buf.len() - limit;
buf.drain(..drop);
truncated = true;
}
let text = String::from_utf8_lossy(&buf).to_string();
(text, truncated)
}
pub fn run_stdout(mut cmd: Command, what: &str) -> Result<Vec<u8>, String> {
let output = cmd
.stdin(Stdio::null())
.stderr(Stdio::piped())
.stdout(Stdio::piped())
.output()
.map_err(|e| format!("{what}: {e}"))?;
if !output.status.success() {
let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(format!(
"{what} failed ({}): {}",
output.status,
if err.is_empty() { "no stderr" } else { &err }
));
}
Ok(output.stdout)
}
pub fn kill_process_tree(pid: u32) {
#[cfg(unix)]
{
_ = nix::sys::signal::killpg(
nix::unistd::Pid::from_raw(pid as i32),
nix::sys::signal::Signal::SIGKILL,
);
}
#[cfg(windows)]
{
_ = Command::new("taskkill")
.args(["/F", "/T", "/PID", &pid.to_string()])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
#[cfg(not(any(unix, windows)))]
{
let _ = pid;
}
}
pub fn blocking<T>(f: impl FnOnce() -> T) -> T {
match tokio::runtime::Handle::try_current() {
Ok(handle) if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread => {
tokio::task::block_in_place(f)
}
_ => f(),
}
}
pub fn spawn_detached(
prefix: &str,
program: &str,
args: &[String],
) -> std::io::Result<(u32, Option<PathBuf>)> {
let log = create_output_file_stream(prefix, ".log").ok();
let mut cmd = Command::new(program);
cmd.args(args).stdin(Stdio::null());
let log_path = match log {
Some((file, path)) => match file.try_clone() {
Ok(stderr) => {
cmd.stdout(Stdio::from(file)).stderr(Stdio::from(stderr));
Some(path)
}
Err(_) => {
cmd.stdout(Stdio::null()).stderr(Stdio::null());
None
}
},
None => {
cmd.stdout(Stdio::null()).stderr(Stdio::null());
None
}
};
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const DETACHED_PROCESS: u32 = 0x0000_0008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
}
let mut child = cmd.spawn()?;
let pid = child.id();
std::thread::spawn(move || {
_ = child.wait();
});
Ok((pid, log_path))
}
pub async fn spawn_blocking<T, F>(f: F) -> Result<T, tokio::task::JoinError>
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
{
tokio::task::spawn_blocking(f).await
}
#[cfg(test)]
mod tests {
use super::*;
use std::{io::Cursor, time::Duration};
#[test]
fn shell_quote_roundtrips_through_tokenize() {
assert_eq!(shell_quote("plain"), "'plain'");
assert_eq!(shell_quote("it's"), "'it'\\''s'");
assert_eq!(shell_quote(""), "''");
for raw in ["plain", "a b", "$HOME"] {
assert_eq!(shell_tokenize(&shell_quote(raw)), vec![raw.to_string()]);
}
}
#[test]
fn wrap_cd_quotes_the_working_directory() {
assert_eq!(wrap_cd("ls", "~"), "ls");
assert_eq!(
wrap_cd("ls", "/srv/x"),
"cd '/srv/x' 2>/dev/null || exit 1; ls"
);
}
#[test]
fn wrap_cd_expands_tilde() {
assert_eq!(
wrap_cd("ls", "~/proj"),
"cd ~/'proj' 2>/dev/null || exit 1; ls"
);
assert_eq!(
wrap_cd("ls", "~/my dir"),
"cd ~/'my dir' 2>/dev/null || exit 1; ls"
);
assert_eq!(
wrap_cd("ls", "~bob/proj"),
"cd ~bob/'proj' 2>/dev/null || exit 1; ls"
);
}
#[test]
fn shell_dir_keeps_tilde_outside_quotes() {
assert_eq!(shell_dir("~"), "~");
assert_eq!(shell_dir("~/"), "~");
assert_eq!(shell_dir("~/proj"), "~/'proj'");
assert_eq!(shell_dir("~/my dir"), "~/'my dir'");
assert_eq!(shell_dir("~bob"), "~bob");
assert_eq!(shell_dir("~bob/proj"), "~bob/'proj'");
assert_eq!(shell_dir("/srv/x"), "'/srv/x'");
assert_eq!(shell_dir("sub dir/x"), "'sub dir/x'");
assert_eq!(shell_dir(""), "''");
assert_eq!(shell_dir("/srv/it's"), "'/srv/it'\\''s'");
}
#[test]
fn shell_dir_produces_a_single_word() {
for dir in [
"~",
"~/proj",
"~/my dir",
"~bob/proj",
"/srv/x",
"sub dir/x",
] {
assert_eq!(
shell_tokenize(&shell_dir(dir)),
vec![dir.to_string()],
"shell_dir({dir:?}) 没能作为一个词交给 shell"
);
}
}
#[test]
fn shell_tokenize_keeps_quoted_arguments_whole() {
let tokens = shell_tokenize("ssh \"my host\" -p 22");
assert_eq!(tokens, vec!["ssh", "my host", "-p", "22"]);
}
#[test]
fn shell_tokenize_ignores_extra_whitespace() {
assert_eq!(shell_tokenize(" a 'b c' "), vec!["a", "b c"]);
assert!(shell_tokenize(" ").is_empty());
}
#[test]
fn drain_tail_keeps_everything_below_the_limit() {
let (text, truncated) = drain_tail(Cursor::new(b"hello".to_vec()), 64);
assert_eq!(text, "hello");
assert!(!truncated);
}
#[test]
fn drain_tail_keeps_only_the_tail_and_flags_it() {
let input = "x".repeat(1000);
let (text, truncated) = drain_tail(Cursor::new(input.into_bytes()), 100);
assert!(truncated);
assert_eq!(text.chars().count(), 100);
}
#[test]
fn drain_tail_rolls_over_multiple_rounds_of_output() {
let (text, truncated) = drain_tail(Cursor::new(vec![b'a'; 100_000]), 128);
assert!(truncated);
assert_eq!(text.len(), 128);
}
#[cfg(unix)]
#[test]
fn shell_command_runs_the_command_string() {
let output = shell_command("echo shell-ok").output().unwrap();
assert!(output.status.success());
assert!(String::from_utf8_lossy(&output.stdout).contains("shell-ok"));
}
#[cfg(unix)]
#[test]
fn kill_process_tree_terminates_a_group_quickly() {
use std::{
os::unix::process::CommandExt as _,
process::Stdio,
time::{Duration, Instant},
};
let mut cmd = shell_command("sleep 30 & wait");
cmd.stdout(Stdio::null()).stderr(Stdio::null());
cmd.process_group(0);
let mut child = cmd.spawn().unwrap();
let pid = child.id();
let started = Instant::now();
kill_process_tree(pid);
_ = child.kill();
_ = child.wait();
assert!(
started.elapsed() < Duration::from_secs(5),
"进程组应被强杀,而不是等满 30s"
);
}
#[cfg(unix)]
#[test]
fn run_stdout_returns_stdout_on_success() {
let out = run_stdout(shell_command("echo run-ok"), "echo").unwrap();
assert!(String::from_utf8_lossy(&out).contains("run-ok"));
}
#[cfg(unix)]
#[test]
fn run_stdout_reports_stderr_on_failure() {
let err = run_stdout(shell_command("echo boom >&2; exit 3"), "boom").unwrap_err();
assert!(err.contains("boom failed"), "{err}");
assert!(err.contains("boom"), "{err}");
}
#[cfg(unix)]
#[test]
fn spawned_output_lands_in_the_log() {
let args = vec!["-c".to_string(), "echo mark-from-child >&2".to_string()];
let (pid, log) = spawn_detached("prux-test-launch", "sh", &args).unwrap();
assert!(pid > 0);
let path = log.expect("launch log path");
for _ in 0..100 {
let text = std::fs::read_to_string(&path).unwrap_or_default();
if text.contains("mark-from-child") {
return;
}
std::thread::sleep(Duration::from_millis(50));
}
panic!(
"child stderr never reached {:?}: {:?}",
path,
std::fs::read_to_string(&path)
);
}
#[cfg(unix)]
#[test]
fn spawned_child_gets_its_own_process_group() {
use nix::unistd::Pid;
let args = vec!["-c".to_string(), "sleep 1".to_string()];
let (pid, _log) = spawn_detached("prux-test-launch", "sh", &args).unwrap();
assert_eq!(
nix::unistd::getpgid(Some(Pid::from_raw(pid as i32))),
Ok(Pid::from_raw(pid as i32))
);
}
#[test]
fn blocking_runs_without_a_runtime() {
assert_eq!(blocking(|| 41 + 1), 42);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn blocking_runs_inside_a_multi_thread_runtime() {
assert_eq!(blocking(|| 6 * 7), 42);
}
}