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//!
12//! ТЗ 0.17.5: при `run_with_progress` stdout/stderr дочернего процесса
13//! перехватываются (`Stdio::piped()`) и накапливаются в фоне — на экране
14//! виден ТОЛЬКО спиннер. При ненулевом коде выхода накопленный вывод
15//! выводится в stderr, чтобы пользователь увидел причину ошибки.
16
17use std::io::{Read as _, Write as _};
18use std::path::Path;
19use std::process::{Command, Stdio};
20use std::sync::Arc;
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::thread;
23use std::time::{Duration, Instant};
24
25use crate::error::{Error, Result};
26
27/// Результат запуска внешней команды.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct CommandOutput {
30    /// Код возврата (`-1`, если процесс завершён сигналом).
31    pub status: i32,
32    /// Стандартный вывод.
33    pub stdout: String,
34    /// Стандартный поток ошибок.
35    pub stderr: String,
36}
37
38impl CommandOutput {
39    /// Успешно ли завершилась команда.
40    pub fn success(&self) -> bool {
41        self.status == 0
42    }
43}
44
45/// Запускает внешние команды. Реализация по умолчанию — [`SystemRunner`].
46pub trait CommandRunner: Send + Sync {
47    /// Запускает `program` с аргументами `args` в каталоге `cwd`.
48    fn run(&self, program: &str, args: &[String], cwd: Option<&Path>) -> Result<CommandOutput>;
49
50    /// Запускает команду с индикатором прогресса в stderr.
51    ///
52    /// Во время выполнения процесса фоновый поток выводит в stderr строку
53    /// `\r⏳ <label> (<n>с)` каждые 2 секунды (обновляя время), и очищает её
54    /// при завершении. По умолчанию делегирует в [`Self::run`] (без спиннера)
55    /// — достаточно для мок-раннеров в тестах.
56    fn run_with_progress(
57        &self,
58        program: &str,
59        args: &[String],
60        cwd: Option<&Path>,
61        label: &str,
62    ) -> Result<CommandOutput> {
63        let _ = label;
64        self.run(program, args, cwd)
65    }
66}
67
68/// Раннер, запускающий реальные процессы через [`std::process::Command`].
69#[derive(Debug, Default, Clone, Copy)]
70pub struct SystemRunner;
71
72impl CommandRunner for SystemRunner {
73    fn run(&self, program: &str, args: &[String], cwd: Option<&Path>) -> Result<CommandOutput> {
74        let mut command = Command::new(program);
75        command.args(args);
76        if let Some(dir) = cwd {
77            command.current_dir(dir);
78        }
79        let output = command.output().map_err(|e| {
80            if e.kind() == std::io::ErrorKind::NotFound {
81                Error::CommandNotFound(program.to_string())
82            } else {
83                Error::Io(e)
84            }
85        })?;
86        Ok(CommandOutput {
87            status: output.status.code().unwrap_or(-1),
88            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
89            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
90        })
91    }
92
93    fn run_with_progress(
94        &self,
95        program: &str,
96        args: &[String],
97        cwd: Option<&Path>,
98        label: &str,
99    ) -> Result<CommandOutput> {
100        let mut command = Command::new(program);
101        command.args(args);
102        if let Some(dir) = cwd {
103            command.current_dir(dir);
104        }
105
106        // Перехватываем stdout/stderr дочернего процесса: на экране виден
107        // только спиннер; вывод накапливается в фоне и выводится при ошибке.
108        command.stdout(Stdio::piped());
109        command.stderr(Stdio::piped());
110
111        // Спинар: фоновый поток пишет `\r⏳ label (Nс)` в stderr каждые 2 с.
112        let stop = Arc::new(AtomicBool::new(false));
113        let spinner = start_spinner(label.to_string(), stop.clone());
114
115        // Запускаем процесс; при ошибке запуска — останавливаем спиннер сразу.
116        let mut child = match command.spawn() {
117            Ok(c) => c,
118            Err(e) => {
119                stop.store(true, Ordering::Relaxed);
120                let _ = spinner.join();
121                return if e.kind() == std::io::ErrorKind::NotFound {
122                    Err(Error::CommandNotFound(program.to_string()))
123                } else {
124                    Err(Error::Io(e))
125                };
126            }
127        };
128
129        // Фоновые потоки читают stdout и stderr в буферы (экран чистый).
130        let stdout_pipe = child.stdout.take();
131        let stderr_pipe = child.stderr.take();
132        let stdout_buf: Arc<std::sync::Mutex<Vec<u8>>> =
133            Arc::new(std::sync::Mutex::new(Vec::new()));
134        let stderr_buf: Arc<std::sync::Mutex<Vec<u8>>> =
135            Arc::new(std::sync::Mutex::new(Vec::new()));
136
137        let out_handle = if let Some(pipe) = stdout_pipe {
138            let buf = Arc::clone(&stdout_buf);
139            Some(thread::spawn(move || {
140                let mut reader = std::io::BufReader::new(pipe);
141                let mut locked = buf.lock().unwrap();
142                let _ = reader.read_to_end(&mut locked);
143            }))
144        } else {
145            None
146        };
147        let err_handle = if let Some(pipe) = stderr_pipe {
148            let buf = Arc::clone(&stderr_buf);
149            Some(thread::spawn(move || {
150                let mut reader = std::io::BufReader::new(pipe);
151                let mut locked = buf.lock().unwrap();
152                let _ = reader.read_to_end(&mut locked);
153            }))
154        } else {
155            None
156        };
157
158        // Ждём завершения процесса.
159        let wait_result = child.wait();
160
161        // Останавливаем спиннер.
162        stop.store(true, Ordering::Relaxed);
163        let _ = spinner.join();
164
165        // Дожидаемся потоков-читальщиков.
166        if let Some(h) = out_handle {
167            let _ = h.join();
168        }
169        if let Some(h) = err_handle {
170            let _ = h.join();
171        }
172
173        let stdout_str = String::from_utf8_lossy(&stdout_buf.lock().unwrap()).into_owned();
174        let stderr_str = String::from_utf8_lossy(&stderr_buf.lock().unwrap()).into_owned();
175
176        let status = match wait_result {
177            Ok(s) => s,
178            Err(e) => {
179                // При ошибке wait выведим накопленный вывод (если есть).
180                if !stdout_str.is_empty() {
181                    eprintln!("{stdout_str}");
182                }
183                if !stderr_str.is_empty() {
184                    eprintln!("{stderr_str}");
185                }
186                return Err(Error::Io(e));
187            }
188        };
189
190        // При ненулевом коде вывода печатаем накопленный вывод в stderr,
191        // чтобы пользователь увидел причину ошибки.
192        let code = status.code().unwrap_or(-1);
193        if code != 0 && !stdout_str.is_empty() {
194            eprintln!("{stdout_str}");
195        }
196        if code != 0 && !stderr_str.is_empty() {
197            eprintln!("{stderr_str}");
198        }
199
200        Ok(CommandOutput {
201            status: code,
202            stdout: stdout_str,
203            stderr: stderr_str,
204        })
205    }
206}
207
208/// Запускает спиннер-поток, который каждые 2 секунды печатает в stderr
209/// `\r⏳ <label> (<n>с)`. Поток останавливается при `stop == true`.
210fn start_spinner(label: String, stop: Arc<AtomicBool>) -> thread::JoinHandle<()> {
211    thread::spawn(move || {
212        let start = Instant::now();
213        loop {
214            thread::sleep(Duration::from_secs(2));
215            if stop.load(Ordering::Relaxed) {
216                break;
217            }
218            let elapsed = start.elapsed().as_secs();
219            let line = format!("\r⏳ {} ({}с)", label, elapsed);
220            // `\r` + строка перезаписывает текущий статус без переноса строки.
221            // Финальный `\x1b[2K` очистит строку при завершении.
222            let mut err = std::io::stderr().lock();
223            let _ = write!(err, "{}", line);
224            let _ = err.flush();
225        }
226        // Очищаем строку при завершении.
227        let mut err = std::io::stderr().lock();
228        let _ = write!(err, "\r\x1b[2K");
229        let _ = err.flush();
230    })
231}
232
233/// Извлекает semver-версию из первой строки вывода `* --version`.
234///
235/// Формат: `<program> <version> (<hash> <date>)`, например
236/// `rustc 1.99.0 (5f94df478 2026-08-27)`.
237pub fn parse_program_version(stdout: &str) -> Option<semver::Version> {
238    let mut parts = stdout.split_whitespace();
239    let _program = parts.next()?;
240    let version = parts.next()?;
241    semver::Version::parse(version).ok()
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn parses_version_line() {
250        let v =
251            parse_program_version("rustc 1.99.0 (5f94df478 2026-08-27)").expect("version parsed");
252        assert_eq!(v, semver::Version::new(1, 99, 0));
253    }
254
255    #[test]
256    fn version_parse_rejects_garbage() {
257        assert!(parse_program_version("no version here").is_none());
258        assert!(parse_program_version("").is_none());
259    }
260
261    #[test]
262    fn run_with_progress_delegates_to_run_for_mock() {
263        struct Mock;
264        impl CommandRunner for Mock {
265            fn run(
266                &self,
267                program: &str,
268                args: &[String],
269                _cwd: Option<&Path>,
270            ) -> Result<CommandOutput> {
271                assert_eq!(program, "echo");
272                let out = args.first().cloned().unwrap_or_default();
273                Ok(CommandOutput {
274                    status: 0,
275                    stdout: out,
276                    stderr: String::new(),
277                })
278            }
279        }
280        let r = Mock.run_with_progress("echo", &["hi".into()], None, "Тест");
281        assert!(r.unwrap().success());
282    }
283}