1use std::{
10 collections::BTreeMap,
11 env,
12 ffi::{OsStr, OsString},
13 io,
14 path::{Path, PathBuf},
15 process::{Output, Stdio},
16};
17
18use smol::{io::AsyncReadExt as _, process::Command, unblock};
19
20use crate::utils::{CommandError, format_failure_stream, std_output_enabled};
21
22mod detached;
23
24#[derive(Debug, Clone)]
32pub struct Host {
33 env: BTreeMap<OsString, OsString>,
34 cwd: PathBuf,
35 home: Option<PathBuf>,
36 app_dirs: Vec<PathBuf>,
37}
38
39impl Host {
40 #[must_use]
45 pub fn current() -> Self {
46 let env = env::vars_os().collect();
47 Self {
48 env,
49 cwd: env::current_dir().expect("process must have a working directory"),
50 home: dirs::home_dir(),
51 app_dirs: default_app_dirs(),
52 }
53 }
54
55 pub fn new<P, K, V>(
73 path_dirs: impl IntoIterator<Item = P>,
74 vars: impl IntoIterator<Item = (K, V)>,
75 ) -> Self
76 where
77 P: AsRef<Path>,
78 K: AsRef<OsStr>,
79 V: AsRef<OsStr>,
80 {
81 let mut env = BTreeMap::new();
82 seed_process_plumbing(&mut env);
83 let path = env::join_paths(
84 path_dirs
85 .into_iter()
86 .map(|dir| dir.as_ref().as_os_str().to_os_string()),
87 )
88 .expect("Host::new PATH entries must join into a valid PATH string");
89 env.insert(OsString::from("PATH"), path);
90 for (key, value) in vars {
91 env.insert(key.as_ref().to_os_string(), value.as_ref().to_os_string());
92 }
93 let home = home_dir_from_env(&env);
94 Self {
95 env,
96 cwd: env::current_dir().expect("process must have a working directory"),
97 home,
98 app_dirs: Vec::new(),
99 }
100 }
101
102 #[must_use]
104 pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
105 self.cwd = cwd.into();
106 self
107 }
108
109 #[must_use]
111 pub fn with_app_dirs(mut self, app_dirs: impl IntoIterator<Item = PathBuf>) -> Self {
112 self.app_dirs = app_dirs.into_iter().collect();
113 self
114 }
115
116 #[must_use]
121 pub fn env(&self, key: impl AsRef<OsStr>) -> Option<&OsStr> {
122 env_get(&self.env, key.as_ref())
123 }
124
125 #[must_use]
127 pub fn env_string(&self, key: impl AsRef<OsStr>) -> Option<String> {
128 self.env(key)
129 .and_then(|value| value.to_str().map(ToOwned::to_owned))
130 }
131
132 #[must_use]
138 pub fn path_entries(&self) -> Vec<PathBuf> {
139 self.env("PATH")
140 .map(|paths| {
141 env::split_paths(paths)
142 .filter(|entry| !entry.as_os_str().is_empty())
143 .collect()
144 })
145 .unwrap_or_default()
146 }
147
148 #[must_use]
150 pub fn cwd(&self) -> &Path {
151 &self.cwd
152 }
153
154 #[must_use]
156 pub fn home_dir(&self) -> Option<&Path> {
157 self.home.as_deref()
158 }
159
160 pub fn current_exe() -> io::Result<PathBuf> {
170 env::current_exe()
171 }
172
173 #[must_use]
179 pub fn app_dirs(&self) -> &[PathBuf] {
180 &self.app_dirs
181 }
182
183 pub async fn which(&self, name: impl AsRef<OsStr>) -> Result<PathBuf, which::Error> {
191 let name = name.as_ref().to_os_string();
192 let paths = self.joined_path();
193 let cwd = self.cwd.clone();
194 unblock(move || which::which_in(name, paths, cwd)).await
195 }
196
197 #[must_use]
203 pub fn with_env(&self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Self {
204 let mut host = self.clone();
205 host.env
206 .insert(key.as_ref().to_os_string(), value.as_ref().to_os_string());
207 host
208 }
209
210 #[must_use]
217 pub fn command(&self, program: impl AsRef<OsStr>) -> Command {
218 withhold_std_handles_from_children();
219 let mut command = Command::new(self.resolve_program(program.as_ref()));
220 command.env_clear().envs(&self.env).current_dir(&self.cwd);
221 command
222 }
223
224 #[must_use]
230 pub fn std_command(&self, program: impl AsRef<OsStr>) -> std::process::Command {
231 withhold_std_handles_from_children();
232 let mut command = std::process::Command::new(self.resolve_program(program.as_ref()));
233 command.env_clear().envs(&self.env).current_dir(&self.cwd);
234 command
235 }
236
237 pub async fn output(
252 &self,
253 program: impl AsRef<OsStr>,
254 args: impl IntoIterator<Item = impl AsRef<OsStr>>,
255 ) -> Result<Output, CommandError> {
256 let program = program.as_ref();
257 let program_name = program.to_string_lossy().into_owned();
258 let args = args
259 .into_iter()
260 .map(|argument| argument.as_ref().to_os_string())
261 .collect::<Vec<_>>();
262 tracing::debug!(program = %program_name, ?args, "spawning");
263 let started = std::time::Instant::now();
264 let mut command = self.command(program);
265 command
266 .args(&args)
267 .kill_on_drop(true)
268 .stdout(Stdio::piped())
269 .stderr(Stdio::piped());
270 let mut child = command.spawn().map_err(|source| CommandError::Spawn {
271 program: program_name.clone(),
272 source,
273 })?;
274
275 let echo = std_output_enabled();
276 let stdout_task = smol::spawn(drain_child_pipe(
277 child.stdout.take().expect("stdout is piped"),
278 io::stdout(),
279 echo,
280 ));
281 let stderr_task = smol::spawn(drain_child_pipe(
282 child.stderr.take().expect("stderr is piped"),
283 io::stderr(),
284 echo,
285 ));
286
287 let status = child.status().await.map_err(|source| CommandError::Spawn {
288 program: program_name.clone(),
289 source,
290 })?;
291 let stdout = stdout_task.await.map_err(|source| CommandError::Spawn {
292 program: program_name.clone(),
293 source,
294 })?;
295 let stderr = stderr_task.await.map_err(|source| CommandError::Spawn {
296 program: program_name.clone(),
297 source,
298 })?;
299 tracing::debug!(
300 program = %program_name,
301 %status,
302 elapsed_ms = started.elapsed().as_millis(),
303 "exited"
304 );
305 Ok(Output {
306 status,
307 stdout,
308 stderr,
309 })
310 }
311
312 pub async fn run(
319 &self,
320 program: impl AsRef<OsStr>,
321 args: impl IntoIterator<Item = impl AsRef<OsStr>>,
322 ) -> Result<String, CommandError> {
323 let program = program.as_ref();
324 let output = self.output(program, args).await?;
325 if output.status.success() {
326 Ok(String::from_utf8_lossy(&output.stdout).to_string())
327 } else {
328 Err(CommandError::Failed {
329 program: program.to_string_lossy().into_owned(),
330 status: output.status,
331 report: format!(
332 "{}{}",
333 format_failure_stream("stderr", &output.stderr),
334 format_failure_stream("stdout", &output.stdout),
335 ),
336 })
337 }
338 }
339
340 pub async fn run_detached(
355 &self,
356 program: impl AsRef<OsStr>,
357 args: impl IntoIterator<Item = impl AsRef<OsStr>>,
358 ) -> Result<std::process::ExitStatus, CommandError> {
359 let program_name = program.as_ref().to_string_lossy().into_owned();
360 let args = args
361 .into_iter()
362 .map(|argument| argument.as_ref().to_os_string())
363 .collect::<Vec<_>>();
364 tracing::debug!(program = %program_name, ?args, "spawning detached");
365 let resolved = self.resolve_program(program.as_ref());
366 let env = self.env.clone();
367 let cwd = self.cwd.clone();
368 let status = unblock(move || detached::run(&resolved, &args, &env, &cwd))
369 .await
370 .map_err(|source| CommandError::Spawn {
371 program: program_name.clone(),
372 source,
373 })?;
374 tracing::debug!(program = %program_name, %status, "detached launcher exited");
375 Ok(status)
376 }
377
378 fn resolve_program(&self, program: &OsStr) -> OsString {
388 let path = Path::new(program);
389 if path.components().count() > 1 {
390 return program.to_os_string();
391 }
392 let paths = self.joined_path();
393 which::which_in(program, paths, &self.cwd)
394 .map_or_else(|_| program.to_os_string(), PathBuf::into_os_string)
395 }
396
397 fn joined_path(&self) -> Option<OsString> {
403 let entries = self.path_entries();
404 if entries.is_empty() {
405 return None;
406 }
407 Some(
408 env::join_paths(entries)
409 .expect("PATH entries produced by split_paths re-join into a PATH string"),
410 )
411 }
412}
413
414#[cfg(windows)]
428fn withhold_std_handles_from_children() {
429 use windows_sys::Win32::{
430 Foundation::{HANDLE_FLAG_INHERIT, INVALID_HANDLE_VALUE, SetHandleInformation},
431 System::Console::{GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE},
432 };
433
434 for (name, id) in [
435 ("stdin", STD_INPUT_HANDLE),
436 ("stdout", STD_OUTPUT_HANDLE),
437 ("stderr", STD_ERROR_HANDLE),
438 ] {
439 let handle = unsafe { GetStdHandle(id) };
441 if handle.is_null() || handle == INVALID_HANDLE_VALUE {
443 continue;
444 }
445 let cleared = unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, 0) };
448 assert!(
449 cleared != 0,
450 "failed to make {name} non-inheritable: {}",
451 io::Error::last_os_error()
452 );
453 }
454}
455
456#[cfg(not(windows))]
457const fn withhold_std_handles_from_children() {
458 }
462
463async fn drain_child_pipe(
471 mut reader: impl smol::io::AsyncRead + Unpin,
472 mut sink: impl io::Write,
473 echo: bool,
474) -> io::Result<Vec<u8>> {
475 let mut collected = Vec::new();
476 let mut chunk = [0u8; 8192];
477 loop {
478 let read = reader.read(&mut chunk).await?;
479 if read == 0 {
480 break;
481 }
482 if echo {
483 let _ = sink.write_all(&chunk[..read]);
484 let _ = sink.flush();
485 }
486 collected.extend_from_slice(&chunk[..read]);
487 }
488 Ok(collected)
489}
490
491fn env_get<'a>(env: &'a BTreeMap<OsString, OsString>, key: &OsStr) -> Option<&'a OsStr> {
493 if cfg!(target_os = "windows") {
494 env.iter()
495 .find(|(existing, _)| existing.as_os_str().eq_ignore_ascii_case(key))
496 .map(|(_, value)| value.as_os_str())
497 } else {
498 env.get(key).map(OsString::as_os_str)
499 }
500}
501
502fn home_dir_from_env(env: &BTreeMap<OsString, OsString>) -> Option<PathBuf> {
504 if cfg!(target_os = "windows") {
505 env_get(env, "USERPROFILE".as_ref())
506 .or_else(|| env_get(env, "HOME".as_ref()))
507 .map(PathBuf::from)
508 } else {
509 env_get(env, "HOME".as_ref())
510 .or_else(|| env_get(env, "USERPROFILE".as_ref()))
511 .map(PathBuf::from)
512 }
513}
514
515fn default_app_dirs() -> Vec<PathBuf> {
517 if cfg!(target_os = "macos") {
518 vec![PathBuf::from("/Applications")]
519 } else {
520 Vec::new()
521 }
522}
523
524#[cfg(target_os = "windows")]
526fn seed_process_plumbing(env: &mut BTreeMap<OsString, OsString>) {
527 for key in ["SystemRoot", "SystemDrive", "windir", "ComSpec", "PATHEXT"] {
528 if let Some(value) = env::var_os(key) {
529 env.entry(OsString::from(key)).or_insert(value);
530 }
531 }
532}
533
534#[cfg(not(target_os = "windows"))]
535const fn seed_process_plumbing(_env: &mut BTreeMap<OsString, OsString>) {}
536
537#[cfg(test)]
538mod tests {
539 use super::Host;
540 use crate::toolchain::testing::TestMachine;
541
542 const UNDECLARED: &str = "WATERUI_TEST_NEVER_DECLARED";
545
546 #[test]
547 fn declared_host_env_contains_only_what_was_declared() {
548 let host = Host::new(
549 Vec::<std::path::PathBuf>::new(),
550 [(String::from("WATERUI_TEST_DECLARED"), String::from("yes"))],
551 );
552 assert_eq!(
553 host.env_string("WATERUI_TEST_DECLARED").as_deref(),
554 Some("yes")
555 );
556 assert!(
557 host.env(UNDECLARED).is_none(),
558 "declared hosts must not see ambient environment variables"
559 );
560 assert!(host.path_entries().is_empty());
562 }
563
564 #[test]
565 fn declared_host_home_comes_from_declared_env() {
566 let machine = TestMachine::new();
567 let host = machine.host(Vec::<(String, String)>::new());
568 assert_eq!(host.home_dir(), Some(machine.home().as_path()));
569 assert_eq!(host.cwd(), machine.root());
570 assert!(
571 host.app_dirs().is_empty(),
572 "declared hosts never see installed application bundles"
573 );
574 }
575
576 #[test]
577 fn which_resolves_only_the_host_path() {
578 let machine = TestMachine::new();
579 let host = machine.host(Vec::<(String, String)>::new());
580 smol::block_on(async {
581 assert!(host.which("waterui-test-missing-tool").await.is_err());
582 assert!(
583 host.which("cargo").await.is_err(),
584 "real cargo must not leak"
585 );
586 machine.install("cargo");
587 let resolved = host
588 .which("cargo")
589 .await
590 .expect("installed fake tool must resolve");
591 assert_eq!(resolved.parent(), Some(machine.bin().as_path()));
592 });
593 }
594
595 #[test]
596 fn spawned_children_see_the_declared_environment() {
597 let machine = TestMachine::new();
598 machine.install("cargo");
599 let host = machine.host([(
600 String::from("WATERUI_FAKE_CARGO_VERSION"),
601 String::from("9.9.9-waterui-test"),
602 )]);
603 let output = smol::block_on(host.run("cargo", ["--version"]))
604 .expect("fake cargo must run under the declared host");
605 assert!(output.contains("9.9.9-waterui-test"));
606 }
607
608 #[test]
609 fn run_reports_nonzero_exit_with_output() {
610 let machine = TestMachine::new();
611 machine.install("rustup");
612 let host = machine.host(Vec::<(String, String)>::new());
613 let error = smol::block_on(host.run("rustup", ["frobnicate"]))
615 .expect_err("a failing tool must surface as an error");
616 assert!(error.to_string().contains("rustup"));
617 }
618}