light_fw_cli_core/
process.rs1use 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#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct CommandOutput {
30 pub status: i32,
32 pub stdout: String,
34 pub stderr: String,
36}
37
38impl CommandOutput {
39 pub fn success(&self) -> bool {
41 self.status == 0
42 }
43}
44
45pub trait CommandRunner: Send + Sync {
47 fn run(&self, program: &str, args: &[String], cwd: Option<&Path>) -> Result<CommandOutput>;
49
50 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#[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 command.stdout(Stdio::piped());
109 command.stderr(Stdio::piped());
110
111 let stop = Arc::new(AtomicBool::new(false));
113 let spinner = start_spinner(label.to_string(), stop.clone());
114
115 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 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 let wait_result = child.wait();
160
161 stop.store(true, Ordering::Relaxed);
163 let _ = spinner.join();
164
165 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 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 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
208fn 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 let mut err = std::io::stderr().lock();
223 let _ = write!(err, "{}", line);
224 let _ = err.flush();
225 }
226 let mut err = std::io::stderr().lock();
228 let _ = write!(err, "\r\x1b[2K");
229 let _ = err.flush();
230 })
231}
232
233pub 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}