use std::io::{Read as _, Write as _};
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use std::time::{Duration, Instant};
use crate::error::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandOutput {
pub status: i32,
pub stdout: String,
pub stderr: String,
}
impl CommandOutput {
pub fn success(&self) -> bool {
self.status == 0
}
}
pub trait CommandRunner: Send + Sync {
fn run(&self, program: &str, args: &[String], cwd: Option<&Path>) -> Result<CommandOutput>;
fn run_with_progress(
&self,
program: &str,
args: &[String],
cwd: Option<&Path>,
label: &str,
) -> Result<CommandOutput> {
let _ = label;
self.run(program, args, cwd)
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct SystemRunner;
impl CommandRunner for SystemRunner {
fn run(&self, program: &str, args: &[String], cwd: Option<&Path>) -> Result<CommandOutput> {
let mut command = Command::new(program);
command.args(args);
if let Some(dir) = cwd {
command.current_dir(dir);
}
let output = command.output().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
Error::CommandNotFound(program.to_string())
} else {
Error::Io(e)
}
})?;
Ok(CommandOutput {
status: output.status.code().unwrap_or(-1),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
})
}
fn run_with_progress(
&self,
program: &str,
args: &[String],
cwd: Option<&Path>,
label: &str,
) -> Result<CommandOutput> {
let mut command = Command::new(program);
command.args(args);
if let Some(dir) = cwd {
command.current_dir(dir);
}
command.stdout(Stdio::piped());
command.stderr(Stdio::piped());
let stop = Arc::new(AtomicBool::new(false));
let spinner = start_spinner(label.to_string(), stop.clone());
let mut child = match command.spawn() {
Ok(c) => c,
Err(e) => {
stop.store(true, Ordering::Relaxed);
let _ = spinner.join();
return if e.kind() == std::io::ErrorKind::NotFound {
Err(Error::CommandNotFound(program.to_string()))
} else {
Err(Error::Io(e))
};
}
};
let stdout_pipe = child.stdout.take();
let stderr_pipe = child.stderr.take();
let stdout_buf: Arc<std::sync::Mutex<Vec<u8>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let stderr_buf: Arc<std::sync::Mutex<Vec<u8>>> =
Arc::new(std::sync::Mutex::new(Vec::new()));
let out_handle = if let Some(pipe) = stdout_pipe {
let buf = Arc::clone(&stdout_buf);
Some(thread::spawn(move || {
let mut reader = std::io::BufReader::new(pipe);
let mut locked = buf.lock().unwrap();
let _ = reader.read_to_end(&mut locked);
}))
} else {
None
};
let err_handle = if let Some(pipe) = stderr_pipe {
let buf = Arc::clone(&stderr_buf);
Some(thread::spawn(move || {
let mut reader = std::io::BufReader::new(pipe);
let mut locked = buf.lock().unwrap();
let _ = reader.read_to_end(&mut locked);
}))
} else {
None
};
let wait_result = child.wait();
stop.store(true, Ordering::Relaxed);
let _ = spinner.join();
if let Some(h) = out_handle {
let _ = h.join();
}
if let Some(h) = err_handle {
let _ = h.join();
}
let stdout_str = String::from_utf8_lossy(&stdout_buf.lock().unwrap()).into_owned();
let stderr_str = String::from_utf8_lossy(&stderr_buf.lock().unwrap()).into_owned();
let status = match wait_result {
Ok(s) => s,
Err(e) => {
if !stdout_str.is_empty() {
eprintln!("{stdout_str}");
}
if !stderr_str.is_empty() {
eprintln!("{stderr_str}");
}
return Err(Error::Io(e));
}
};
let code = status.code().unwrap_or(-1);
if code != 0 && !stdout_str.is_empty() {
eprintln!("{stdout_str}");
}
if code != 0 && !stderr_str.is_empty() {
eprintln!("{stderr_str}");
}
Ok(CommandOutput {
status: code,
stdout: stdout_str,
stderr: stderr_str,
})
}
}
fn start_spinner(label: String, stop: Arc<AtomicBool>) -> thread::JoinHandle<()> {
thread::spawn(move || {
let start = Instant::now();
loop {
thread::sleep(Duration::from_secs(2));
if stop.load(Ordering::Relaxed) {
break;
}
let elapsed = start.elapsed().as_secs();
let line = format!("\r⏳ {} ({}с)", label, elapsed);
let mut err = std::io::stderr().lock();
let _ = write!(err, "{}", line);
let _ = err.flush();
}
let mut err = std::io::stderr().lock();
let _ = write!(err, "\r\x1b[2K");
let _ = err.flush();
})
}
pub fn parse_program_version(stdout: &str) -> Option<semver::Version> {
let mut parts = stdout.split_whitespace();
let _program = parts.next()?;
let version = parts.next()?;
semver::Version::parse(version).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_version_line() {
let v =
parse_program_version("rustc 1.99.0 (5f94df478 2026-08-27)").expect("version parsed");
assert_eq!(v, semver::Version::new(1, 99, 0));
}
#[test]
fn version_parse_rejects_garbage() {
assert!(parse_program_version("no version here").is_none());
assert!(parse_program_version("").is_none());
}
#[test]
fn run_with_progress_delegates_to_run_for_mock() {
struct Mock;
impl CommandRunner for Mock {
fn run(
&self,
program: &str,
args: &[String],
_cwd: Option<&Path>,
) -> Result<CommandOutput> {
assert_eq!(program, "echo");
let out = args.first().cloned().unwrap_or_default();
Ok(CommandOutput {
status: 0,
stdout: out,
stderr: String::new(),
})
}
}
let r = Mock.run_with_progress("echo", &["hi".into()], None, "Тест");
assert!(r.unwrap().success());
}
}