light_fw_cli_core/
process.rs1use 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#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct CommandOutput {
25 pub status: i32,
27 pub stdout: String,
29 pub stderr: String,
31}
32
33impl CommandOutput {
34 pub fn success(&self) -> bool {
36 self.status == 0
37 }
38}
39
40pub trait CommandRunner: Send + Sync {
42 fn run(&self, program: &str, args: &[String], cwd: Option<&Path>) -> Result<CommandOutput>;
44
45 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#[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 let stop = Arc::new(AtomicBool::new(false));
103 let spinner = start_spinner(label.to_string(), stop.clone());
104
105 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 let output = wait_with_output(child);
121
122 stop.store(true, Ordering::Relaxed);
124 let _ = spinner.join();
125
126 Ok(output)
127 }
128}
129
130fn 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 let mut err = std::io::stderr().lock();
145 let _ = write!(err, "{}", line);
146 let _ = err.flush();
147 }
148 let mut err = std::io::stderr().lock();
150 let _ = write!(err, "\r\x1b[2K");
151 let _ = err.flush();
152 })
153}
154
155fn 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
171pub 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}