Skip to main content

light_fw_cli_core/
process.rs

1//! Обёртка над запуском внешних процессов.
2//!
3//! Реальная работа с `cargo`/`rustc`/`rustup` идёт через трейт [`CommandRunner`],
4//! что позволяет тестировать логику CLI без запуска настоящих процессов
5//! (в тестах подставляется мок-раннер).
6//!
7//! ТЗ 0.17.4: [`SystemRunner`] поддерживает индикатор прогресса — во время
8//! блокирующего вызова (`output()`, `wait()`) фоновый поток выводит в stderr
9//! `\r⏳ <label> (<n>с)` каждые 2 секунды, чтобы пользователь видел, что
10//! процесс ещё жив. При завершении строка очищается.
11
12use std::io::Write as _;
13use std::path::Path;
14use std::process::{Child, Command};
15use std::sync::atomic::{AtomicBool, Ordering};
16use std::sync::Arc;
17use std::thread;
18use std::time::{Duration, Instant};
19
20use crate::error::{Error, Result};
21
22/// Результат запуска внешней команды.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct CommandOutput {
25    /// Код возврата (`-1`, если процесс завершён сигналом).
26    pub status: i32,
27    /// Стандартный вывод.
28    pub stdout: String,
29    /// Стандартный поток ошибок.
30    pub stderr: String,
31}
32
33impl CommandOutput {
34    /// Успешно ли завершилась команда.
35    pub fn success(&self) -> bool {
36        self.status == 0
37    }
38}
39
40/// Запускает внешние команды. Реализация по умолчанию — [`SystemRunner`].
41pub trait CommandRunner: Send + Sync {
42    /// Запускает `program` с аргументами `args` в каталоге `cwd`.
43    fn run(&self, program: &str, args: &[String], cwd: Option<&Path>) -> Result<CommandOutput>;
44
45    /// Запускает команду с индикатором прогресса в stderr.
46    ///
47    /// Во время выполнения процесса фоновый поток выводит в stderr строку
48    /// `\r⏳ <label> (<n>с)` каждые 2 секунды (обновляя время), и очищает её
49    /// при завершении. По умолчанию делегирует в [`Self::run`] (без спиннера)
50    /// — достаточно для мок-раннеров в тестах.
51    fn run_with_progress(
52        &self,
53        program: &str,
54        args: &[String],
55        cwd: Option<&Path>,
56        label: &str,
57    ) -> Result<CommandOutput> {
58        let _ = label;
59        self.run(program, args, cwd)
60    }
61}
62
63/// Раннер, запускающий реальные процессы через [`std::process::Command`].
64#[derive(Debug, Default, Clone, Copy)]
65pub struct SystemRunner;
66
67impl CommandRunner for SystemRunner {
68    fn run(&self, program: &str, args: &[String], cwd: Option<&Path>) -> Result<CommandOutput> {
69        let mut command = Command::new(program);
70        command.args(args);
71        if let Some(dir) = cwd {
72            command.current_dir(dir);
73        }
74        let output = command.output().map_err(|e| {
75            if e.kind() == std::io::ErrorKind::NotFound {
76                Error::CommandNotFound(program.to_string())
77            } else {
78                Error::Io(e)
79            }
80        })?;
81        Ok(CommandOutput {
82            status: output.status.code().unwrap_or(-1),
83            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
84            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
85        })
86    }
87
88    fn run_with_progress(
89        &self,
90        program: &str,
91        args: &[String],
92        cwd: Option<&Path>,
93        label: &str,
94    ) -> Result<CommandOutput> {
95        let mut command = Command::new(program);
96        command.args(args);
97        if let Some(dir) = cwd {
98            command.current_dir(dir);
99        }
100
101        // Спинар: фоновый поток пишет `\r⏳ label (Nс)` в stderr каждые 2 с.
102        let stop = Arc::new(AtomicBool::new(false));
103        let spinner = start_spinner(label.to_string(), stop.clone());
104
105        // Запускаем процесс; при ошибке запуска — останавливаем спиннер сразу.
106        let child = match command.spawn() {
107            Ok(c) => c,
108            Err(e) => {
109                stop.store(true, Ordering::Relaxed);
110                let _ = spinner.join();
111                return if e.kind() == std::io::ErrorKind::NotFound {
112                    Err(Error::CommandNotFound(program.to_string()))
113                } else {
114                    Err(Error::Io(e))
115                };
116            }
117        };
118
119        // Ожидаем завершение; stdout/stderr захвачены (pipelines).
120        let output = wait_with_output(child);
121
122        // Останавливаем спиннер и ожидаем его завершения.
123        stop.store(true, Ordering::Relaxed);
124        let _ = spinner.join();
125
126        Ok(output)
127    }
128}
129
130/// Запускает спиннер-поток, который каждые 2 секунды печатает в stderr
131/// `\r⏳ <label> (<n>с)`. Поток останавливается при `stop == true`.
132fn start_spinner(label: String, stop: Arc<AtomicBool>) -> thread::JoinHandle<()> {
133    thread::spawn(move || {
134        let start = Instant::now();
135        loop {
136            thread::sleep(Duration::from_secs(2));
137            if stop.load(Ordering::Relaxed) {
138                break;
139            }
140            let elapsed = start.elapsed().as_secs();
141            let line = format!("\r⏳ {} ({}с)", label, elapsed);
142            // `\r` + строка перезаписывает текущий статус без переноса строки.
143            // Финальный `\x1b[2K` очистит строку при завершении.
144            let mut err = std::io::stderr().lock();
145            let _ = write!(err, "{}", line);
146            let _ = err.flush();
147        }
148        // Очищаем строку при завершении.
149        let mut err = std::io::stderr().lock();
150        let _ = write!(err, "\r\x1b[2K");
151        let _ = err.flush();
152    })
153}
154
155/// Ждёт завершения дочернего процесса и собирает его stdout/stderr.
156fn wait_with_output(child: Child) -> CommandOutput {
157    match child.wait_with_output() {
158        Ok(output) => CommandOutput {
159            status: output.status.code().unwrap_or(-1),
160            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
161            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
162        },
163        Err(_) => CommandOutput {
164            status: -1,
165            stdout: String::new(),
166            stderr: "не удалось дождаться завершения процесса".to_string(),
167        },
168    }
169}
170
171/// Извлекает semver-версию из первой строки вывода `* --version`.
172///
173/// Формат: `<program> <version> (<hash> <date>)`, например
174/// `rustc 1.99.0 (5f94df478 2026-08-27)`.
175pub fn parse_program_version(stdout: &str) -> Option<semver::Version> {
176    let mut parts = stdout.split_whitespace();
177    let _program = parts.next()?;
178    let version = parts.next()?;
179    semver::Version::parse(version).ok()
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn parses_version_line() {
188        let v =
189            parse_program_version("rustc 1.99.0 (5f94df478 2026-08-27)").expect("version parsed");
190        assert_eq!(v, semver::Version::new(1, 99, 0));
191    }
192
193    #[test]
194    fn version_parse_rejects_garbage() {
195        assert!(parse_program_version("no version here").is_none());
196        assert!(parse_program_version("").is_none());
197    }
198
199    #[test]
200    fn run_with_progress_delegates_to_run_for_mock() {
201        struct Mock;
202        impl CommandRunner for Mock {
203            fn run(&self, program: &str, args: &[String], _cwd: Option<&Path>) -> Result<CommandOutput> {
204                assert_eq!(program, "echo");
205                let out = args.first().cloned().unwrap_or_default();
206                Ok(CommandOutput { status: 0, stdout: out, stderr: String::new() })
207            }
208        }
209        let r = Mock.run_with_progress("echo", &["hi".into()], None, "Тест");
210        assert!(r.unwrap().success());
211    }
212}