use std::io::Write as _;
use std::path::Path;
use std::process::{Child, Command};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
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);
}
let stop = Arc::new(AtomicBool::new(false));
let spinner = start_spinner(label.to_string(), stop.clone());
let 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 output = wait_with_output(child);
stop.store(true, Ordering::Relaxed);
let _ = spinner.join();
Ok(output)
}
}
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();
})
}
fn wait_with_output(child: Child) -> CommandOutput {
match child.wait_with_output() {
Ok(output) => 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(),
},
Err(_) => CommandOutput {
status: -1,
stdout: String::new(),
stderr: "не удалось дождаться завершения процесса".to_string(),
},
}
}
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());
}
}