horto_os_ui_shared/remote/
process.rs1use crate::error::{HortoError, Result};
4use std::io::Write;
5use std::process::{Command, Stdio};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum StdioMode {
10 Capture,
12 Inherit,
14}
15
16#[derive(Debug, Clone)]
18pub struct CommandOutput {
19 pub status: i32,
21 pub stdout: String,
23 pub stderr: String,
25}
26
27impl CommandOutput {
28 #[must_use]
30 pub const fn success(&self) -> bool {
31 self.status == 0
32 }
33}
34
35pub trait ProcessRunner {
37 fn run(
43 &self,
44 program: &str,
45 args: &[&str],
46 env: &[(&str, &str)],
47 stdio: StdioMode,
48 ) -> Result<CommandOutput>;
49
50 fn run_with_stdin(
58 &self,
59 program: &str,
60 args: &[&str],
61 env: &[(&str, &str)],
62 stdin: &[u8],
63 ) -> Result<CommandOutput> {
64 let _ = (program, args, env, stdin);
65 Err(HortoError::msg(
66 "process runner does not support stdin feed",
67 ))
68 }
69}
70
71#[derive(Debug, Default, Clone, Copy)]
73pub struct SystemProcessRunner;
74
75impl ProcessRunner for SystemProcessRunner {
76 fn run(
77 &self,
78 program: &str,
79 args: &[&str],
80 env: &[(&str, &str)],
81 stdio: StdioMode,
82 ) -> Result<CommandOutput> {
83 let mut cmd = Command::new(program);
84 cmd.args(args);
85 for (k, v) in env {
86 cmd.env(k, v);
87 }
88 match stdio {
89 StdioMode::Capture => {
90 cmd.stdin(Stdio::null())
91 .stdout(Stdio::piped())
92 .stderr(Stdio::piped());
93 let out = cmd
94 .output()
95 .map_err(|e| HortoError::command(program, e.to_string()))?;
96 Ok(CommandOutput {
97 status: out.status.code().unwrap_or(-1),
98 stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
99 stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
100 })
101 }
102 StdioMode::Inherit => {
103 cmd.stdin(Stdio::inherit())
104 .stdout(Stdio::inherit())
105 .stderr(Stdio::inherit());
106 let status = cmd
107 .status()
108 .map_err(|e| HortoError::command(program, e.to_string()))?;
109 Ok(CommandOutput {
110 status: status.code().unwrap_or(-1),
111 stdout: String::new(),
112 stderr: String::new(),
113 })
114 }
115 }
116 }
117
118 fn run_with_stdin(
119 &self,
120 program: &str,
121 args: &[&str],
122 env: &[(&str, &str)],
123 stdin: &[u8],
124 ) -> Result<CommandOutput> {
125 let mut cmd = Command::new(program);
126 cmd.args(args);
127 for (k, v) in env {
128 cmd.env(k, v);
129 }
130 cmd.stdin(Stdio::piped())
131 .stdout(Stdio::piped())
132 .stderr(Stdio::piped());
133 let mut child = cmd
134 .spawn()
135 .map_err(|e| HortoError::command(program, e.to_string()))?;
136 {
137 let Some(pipe) = child.stdin.as_mut() else {
138 return Err(HortoError::command(program, "stdin pipe missing"));
139 };
140 pipe.write_all(stdin)
141 .map_err(|e| HortoError::command(program, format!("stdin write: {e}")))?;
142 }
143 drop(child.stdin.take());
145 let out = child
146 .wait_with_output()
147 .map_err(|e| HortoError::command(program, e.to_string()))?;
148 Ok(CommandOutput {
149 status: out.status.code().unwrap_or(-1),
150 stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
151 stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
152 })
153 }
154}
155
156#[cfg(test)]
158pub type RecordedCall = (String, Vec<String>, Vec<(String, String)>, StdioMode);
159
160#[cfg(test)]
162#[derive(Debug, Default)]
163pub struct ScriptedRunner {
164 scripts: std::sync::Mutex<std::collections::HashMap<String, Vec<CommandOutput>>>,
165 pub calls: std::sync::Mutex<Vec<RecordedCall>>,
167}
168
169#[cfg(test)]
170impl ScriptedRunner {
171 pub fn push(&self, program: &str, output: CommandOutput) {
173 self.scripts
174 .lock()
175 .expect("lock")
176 .entry(program.to_owned())
177 .or_default()
178 .push(output);
179 }
180
181 #[must_use]
183 pub fn ok(stdout: &str) -> CommandOutput {
184 CommandOutput {
185 status: 0,
186 stdout: stdout.to_owned(),
187 stderr: String::new(),
188 }
189 }
190
191 #[must_use]
193 pub fn fail(status: i32, stderr: &str) -> CommandOutput {
194 CommandOutput {
195 status,
196 stdout: String::new(),
197 stderr: stderr.to_owned(),
198 }
199 }
200}
201
202#[cfg(test)]
203impl ProcessRunner for ScriptedRunner {
204 fn run(
205 &self,
206 program: &str,
207 args: &[&str],
208 env: &[(&str, &str)],
209 stdio: StdioMode,
210 ) -> Result<CommandOutput> {
211 self.calls.lock().expect("lock").push((
212 program.to_owned(),
213 args.iter().map(|s| (*s).to_owned()).collect(),
214 env.iter()
215 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
216 .collect(),
217 stdio,
218 ));
219 let mut map = self.scripts.lock().expect("lock");
220 let queue = map.get_mut(program).ok_or_else(|| {
221 HortoError::msg(format!("scripted runner: unexpected program {program}"))
222 })?;
223 if queue.is_empty() {
224 return Err(HortoError::msg(format!(
225 "scripted runner: no more replies for {program}"
226 )));
227 }
228 let out = queue.remove(0);
229 drop(map);
230 Ok(out)
231 }
232
233 fn run_with_stdin(
234 &self,
235 program: &str,
236 args: &[&str],
237 env: &[(&str, &str)],
238 stdin: &[u8],
239 ) -> Result<CommandOutput> {
240 let _ = stdin;
241 self.run(program, args, env, StdioMode::Capture)
242 }
243}
244
245#[cfg(test)]
246mod system_tests {
247 use super::*;
248
249 #[test]
250 fn command_output_success() {
251 assert!(CommandOutput {
252 status: 0,
253 stdout: String::new(),
254 stderr: String::new(),
255 }
256 .success());
257 assert!(!CommandOutput {
258 status: 1,
259 stdout: String::new(),
260 stderr: String::new(),
261 }
262 .success());
263 }
264
265 #[test]
266 fn system_runner_capture_echo() {
267 let out = SystemProcessRunner
268 .run("echo", &["hello-remote"], &[], StdioMode::Capture)
269 .unwrap();
270 assert!(out.success());
271 assert!(out.stdout.contains("hello-remote"));
272 }
273
274 #[test]
275 fn system_runner_inherit_true() {
276 let out = SystemProcessRunner
277 .run("true", &[], &[], StdioMode::Inherit)
278 .unwrap();
279 assert!(out.success());
280 assert_eq!(out.stdout, "");
281 }
282
283 #[test]
284 fn system_runner_run_with_stdin_cat() {
285 let out = SystemProcessRunner
286 .run_with_stdin("/bin/cat", &[], &[("LC_ALL", "C")], b"stdin-bytes")
287 .unwrap();
288 assert!(out.success());
289 assert!(out.stdout.contains("stdin-bytes"));
290 }
291
292 #[test]
293 fn system_runner_run_with_stdin_missing_program() {
294 let err = SystemProcessRunner
295 .run_with_stdin("horto-os-ui-definitely-missing-bin-xyz", &[], &[], b"x")
296 .unwrap_err();
297 assert!(err.to_string().contains("horto-os-ui-definitely-missing"));
298 }
299
300 #[test]
301 fn system_runner_missing_program() {
302 let err = SystemProcessRunner
303 .run(
304 "horto-os-ui-definitely-missing-bin-xyz",
305 &[],
306 &[],
307 StdioMode::Capture,
308 )
309 .unwrap_err();
310 assert!(err.to_string().contains("horto-os-ui-definitely-missing"));
311 }
312
313 #[test]
314 fn scripted_run_with_stdin_delegates_to_run() {
315 let runner = ScriptedRunner::default();
316 runner.push("ssh", ScriptedRunner::ok("fed"));
317 let out = runner
318 .run_with_stdin("ssh", &["-n"], &[("A", "1")], b"secret")
319 .unwrap();
320 assert!(out.success());
321 assert_eq!(out.stdout, "fed");
322 let calls = runner.calls.lock().expect("lock");
323 assert_eq!(calls.len(), 1);
324 assert_eq!(calls[0].0, "ssh");
325 assert_eq!(calls[0].3, StdioMode::Capture);
326 drop(calls);
327 }
328
329 #[test]
330 fn default_run_with_stdin_rejects() {
331 struct OnlyRun;
332 impl ProcessRunner for OnlyRun {
333 fn run(
334 &self,
335 _program: &str,
336 _args: &[&str],
337 _env: &[(&str, &str)],
338 _stdio: StdioMode,
339 ) -> Result<CommandOutput> {
340 Ok(ScriptedRunner::ok(""))
341 }
342 }
343 let err = OnlyRun.run_with_stdin("ssh", &[], &[], b"x").unwrap_err();
344 assert!(err.to_string().contains("does not support stdin"));
345 }
346
347 #[test]
348 fn scripted_unexpected_and_exhausted() {
349 let runner = ScriptedRunner::default();
350 let err = runner
351 .run("nope", &[], &[], StdioMode::Capture)
352 .unwrap_err();
353 assert!(err.to_string().contains("unexpected"));
354 runner.push("once", ScriptedRunner::ok(""));
355 runner.run("once", &[], &[], StdioMode::Capture).unwrap();
356 let err = runner
357 .run("once", &[], &[], StdioMode::Capture)
358 .unwrap_err();
359 assert!(err.to_string().contains("no more replies"));
360 }
361}