| use std::io::{self, Write}; |
| use std::path::{Path, PathBuf}; |
| use std::sync::Arc; |
|
|
| use bstr::ByteSlice; |
| use forge_app::CommandInfra; |
| use forge_domain::{CommandOutput, ConsoleWriter as OutputPrinterTrait, Environment}; |
| use tokio::io::AsyncReadExt; |
| use tokio::process::Command; |
| use tokio::sync::Mutex; |
|
|
| use crate::console::StdConsoleWriter; |
|
|
| |
| #[derive(Clone, Debug)] |
| pub struct ForgeCommandExecutorService { |
| env: Environment, |
| output_printer: Arc<StdConsoleWriter>, |
|
|
| |
| ready: Arc<Mutex<()>>, |
| } |
|
|
| impl ForgeCommandExecutorService { |
| pub fn new(env: Environment, output_printer: Arc<StdConsoleWriter>) -> Self { |
| Self { env, output_printer, ready: Arc::new(Mutex::new(())) } |
| } |
|
|
| fn prepare_command( |
| &self, |
| command_str: &str, |
| working_dir: &Path, |
| env_vars: Option<Vec<String>>, |
| ) -> Command { |
| |
| let is_windows = cfg!(target_os = "windows"); |
| let shell = self.env.shell.as_str(); |
| let mut command = Command::new(shell); |
|
|
| |
| command |
| .env("CLICOLOR_FORCE", "1") |
| .env("FORCE_COLOR", "true") |
| .env_remove("NO_COLOR"); |
|
|
| |
| command |
| .env("SBT_OPTS", "-Dsbt.color=always") |
| .env("JAVA_OPTS", "-Dsbt.color=always"); |
|
|
| |
| command.env("GIT_CONFIG_PARAMETERS", "'color.ui=always'"); |
|
|
| |
| command.env("GREP_OPTIONS", "--color=always"); |
|
|
| let parameter = if is_windows { "/C" } else { "-c" }; |
| command.arg(parameter); |
|
|
| #[cfg(windows)] |
| command.raw_arg(command_str); |
| #[cfg(unix)] |
| command.arg(command_str); |
|
|
| tracing::info!(command = command_str, "Executing command"); |
|
|
| command.kill_on_drop(true); |
|
|
| |
| command.current_dir(working_dir); |
|
|
| |
| command |
| .stdin(std::process::Stdio::inherit()) |
| .stdout(std::process::Stdio::piped()) |
| .stderr(std::process::Stdio::piped()); |
|
|
| |
| if let Some(env_vars) = env_vars { |
| for env_var in env_vars { |
| if let Ok(value) = std::env::var(&env_var) { |
| command.env(&env_var, value); |
| tracing::debug!(env_var = %env_var, "Set environment variable from system"); |
| } else { |
| tracing::warn!(env_var = %env_var, "Environment variable not found in system"); |
| } |
| } |
| } |
|
|
| command |
| } |
|
|
| |
| async fn execute_command_internal( |
| &self, |
| command: String, |
| working_dir: &Path, |
| silent: bool, |
| env_vars: Option<Vec<String>>, |
| ) -> anyhow::Result<CommandOutput> { |
| let ready = self.ready.lock().await; |
|
|
| let mut prepared_command = self.prepare_command(&command, working_dir, env_vars); |
|
|
| |
| let mut child = prepared_command.spawn()?; |
|
|
| let mut stdout_pipe = child.stdout.take(); |
| let mut stderr_pipe = child.stderr.take(); |
|
|
| |
| let (status, stdout_buffer, stderr_buffer) = if silent { |
| tokio::try_join!( |
| child.wait(), |
| stream(&mut stdout_pipe, io::sink()), |
| stream(&mut stderr_pipe, io::sink()) |
| )? |
| } else { |
| let stdout_writer = OutputPrinterWriter::stdout(self.output_printer.clone()); |
| let stderr_writer = OutputPrinterWriter::stderr(self.output_printer.clone()); |
| let result = tokio::try_join!( |
| child.wait(), |
| stream(&mut stdout_pipe, stdout_writer), |
| stream(&mut stderr_pipe, stderr_writer) |
| )?; |
|
|
| |
| |
| |
| if result.1.last() != Some(&b'\n') && !result.1.is_empty() { |
| let _ = self.output_printer.write(b"\n"); |
| let _ = self.output_printer.flush(); |
| } |
|
|
| result |
| }; |
|
|
| |
| drop(stdout_pipe); |
| drop(stderr_pipe); |
| drop(ready); |
|
|
| Ok(CommandOutput { |
| stdout: stdout_buffer.to_str_lossy().into_owned(), |
| stderr: stderr_buffer.to_str_lossy().into_owned(), |
| exit_code: status.code(), |
| command, |
| }) |
| } |
| } |
|
|
| |
| struct OutputPrinterWriter { |
| printer: Arc<StdConsoleWriter>, |
| is_stdout: bool, |
| } |
|
|
| impl OutputPrinterWriter { |
| fn stdout(printer: Arc<StdConsoleWriter>) -> Self { |
| Self { printer, is_stdout: true } |
| } |
|
|
| fn stderr(printer: Arc<StdConsoleWriter>) -> Self { |
| Self { printer, is_stdout: false } |
| } |
| } |
|
|
| impl Write for OutputPrinterWriter { |
| fn write(&mut self, buf: &[u8]) -> io::Result<usize> { |
| if self.is_stdout { |
| self.printer.write(buf) |
| } else { |
| self.printer.write_err(buf) |
| } |
| } |
|
|
| fn flush(&mut self) -> io::Result<()> { |
| if self.is_stdout { |
| self.printer.flush() |
| } else { |
| self.printer.flush_err() |
| } |
| } |
| } |
|
|
| |
| async fn stream<A: AsyncReadExt + Unpin, W: Write>( |
| io: &mut Option<A>, |
| mut writer: W, |
| ) -> io::Result<Vec<u8>> { |
| let mut output = Vec::new(); |
| if let Some(io) = io.as_mut() { |
| let mut buff = [0; 1024]; |
| |
| |
| let mut pending = Vec::<u8>::new(); |
| loop { |
| let n = io.read(&mut buff).await?; |
| if n == 0 { |
| break; |
| } |
| let chunk = buff.get(..n).unwrap_or(&[]); |
| output.extend_from_slice(chunk); |
|
|
| let mut working = std::mem::take(&mut pending); |
| working.extend_from_slice(chunk); |
| pending = write_lossy_utf8(&mut writer, &working)?; |
| |
| writer.flush()?; |
| } |
| |
| if !pending.is_empty() { |
| writer.write_all(pending.to_str_lossy().as_bytes())?; |
| writer.flush()?; |
| } |
| } |
| Ok(output) |
| } |
|
|
| |
| |
| |
| fn write_lossy_utf8<W: Write>(writer: &mut W, buf: &[u8]) -> io::Result<Vec<u8>> { |
| let mut chunks = ByteSlice::utf8_chunks(buf).peekable(); |
|
|
| while let Some(chunk) = chunks.next() { |
| writer.write_all(chunk.valid().as_bytes())?; |
|
|
| if !chunk.invalid().is_empty() { |
| if chunk.incomplete() && chunks.peek().is_none() { |
| return Ok(chunk.invalid().to_vec()); |
| } |
| writer.write_all("\u{FFFD}".as_bytes())?; |
| } |
| } |
|
|
| Ok(Vec::new()) |
| } |
|
|
| |
| #[async_trait::async_trait] |
| impl CommandInfra for ForgeCommandExecutorService { |
| async fn execute_command( |
| &self, |
| command: String, |
| working_dir: PathBuf, |
| silent: bool, |
| env_vars: Option<Vec<String>>, |
| ) -> anyhow::Result<CommandOutput> { |
| self.execute_command_internal(command, &working_dir, silent, env_vars) |
| .await |
| } |
|
|
| async fn execute_command_raw( |
| &self, |
| command: &str, |
| working_dir: PathBuf, |
| env_vars: Option<Vec<String>>, |
| ) -> anyhow::Result<std::process::ExitStatus> { |
| let mut prepared_command = self.prepare_command(command, &working_dir, env_vars); |
|
|
| |
| prepared_command |
| .stdin(std::process::Stdio::inherit()) |
| .stdout(std::process::Stdio::inherit()) |
| .stderr(std::process::Stdio::inherit()); |
|
|
| Ok(prepared_command.spawn()?.wait().await?) |
| } |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
|
|
| use pretty_assertions::assert_eq; |
|
|
| use super::*; |
|
|
| fn test_env() -> Environment { |
| use fake::{Fake, Faker}; |
| let fixture: Environment = Faker.fake(); |
| fixture.shell( |
| if cfg!(target_os = "windows") { |
| "cmd" |
| } else { |
| "bash" |
| } |
| .to_string(), |
| ) |
| } |
|
|
| fn test_printer() -> Arc<StdConsoleWriter> { |
| Arc::new(StdConsoleWriter::default()) |
| } |
|
|
| #[tokio::test] |
| async fn test_command_executor() { |
| let fixture = ForgeCommandExecutorService::new(test_env(), test_printer()); |
| let cmd = "echo 'hello world'"; |
| let dir = "."; |
|
|
| let actual = fixture |
| .execute_command(cmd.to_string(), PathBuf::new().join(dir), false, None) |
| .await |
| .unwrap(); |
|
|
| let mut expected = CommandOutput { |
| stdout: "hello world\n".to_string(), |
| stderr: "".to_string(), |
| command: "echo \"hello world\"".into(), |
| exit_code: Some(0), |
| }; |
|
|
| if cfg!(target_os = "windows") { |
| expected.stdout = format!("'{}'", expected.stdout); |
| } |
|
|
| assert_eq!(actual.stdout.trim(), expected.stdout.trim()); |
| assert_eq!(actual.stderr, expected.stderr); |
| assert_eq!(actual.success(), expected.success()); |
| } |
| #[tokio::test] |
| async fn test_command_executor_with_env_vars_success() { |
| |
| unsafe { |
| std::env::set_var("TEST_ENV_VAR", "test_value"); |
| std::env::set_var("ANOTHER_TEST_VAR", "another_value"); |
| } |
|
|
| let fixture = ForgeCommandExecutorService::new(test_env(), test_printer()); |
| let cmd = if cfg!(target_os = "windows") { |
| "echo %TEST_ENV_VAR%" |
| } else { |
| "echo $TEST_ENV_VAR" |
| }; |
|
|
| let actual = fixture |
| .execute_command( |
| cmd.to_string(), |
| PathBuf::new().join("."), |
| false, |
| Some(vec!["TEST_ENV_VAR".to_string()]), |
| ) |
| .await |
| .unwrap(); |
|
|
| assert!(actual.success()); |
| assert!(actual.stdout.contains("test_value")); |
|
|
| |
| unsafe { |
| std::env::remove_var("TEST_ENV_VAR"); |
| std::env::remove_var("ANOTHER_TEST_VAR"); |
| } |
| } |
|
|
| #[tokio::test] |
| async fn test_command_executor_with_missing_env_vars() { |
| unsafe { |
| std::env::remove_var("MISSING_ENV_VAR"); |
| } |
|
|
| let fixture = ForgeCommandExecutorService::new(test_env(), test_printer()); |
| let cmd = if cfg!(target_os = "windows") { |
| "echo %MISSING_ENV_VAR%" |
| } else { |
| "echo ${MISSING_ENV_VAR:-default_value}" |
| }; |
|
|
| let actual = fixture |
| .execute_command( |
| cmd.to_string(), |
| PathBuf::new().join("."), |
| false, |
| Some(vec!["MISSING_ENV_VAR".to_string()]), |
| ) |
| .await |
| .unwrap(); |
|
|
| |
| assert!(actual.success()); |
| } |
|
|
| #[tokio::test] |
| async fn test_command_executor_with_empty_env_list() { |
| let fixture = ForgeCommandExecutorService::new(test_env(), test_printer()); |
| let cmd = "echo 'no env vars'"; |
|
|
| let actual = fixture |
| .execute_command( |
| cmd.to_string(), |
| PathBuf::new().join("."), |
| false, |
| Some(vec![]), |
| ) |
| .await |
| .unwrap(); |
|
|
| assert!(actual.success()); |
| assert!(actual.stdout.contains("no env vars")); |
| } |
|
|
| #[tokio::test] |
| async fn test_command_executor_with_multiple_env_vars() { |
| unsafe { |
| std::env::set_var("FIRST_VAR", "first"); |
| std::env::set_var("SECOND_VAR", "second"); |
| } |
|
|
| let fixture = ForgeCommandExecutorService::new(test_env(), test_printer()); |
| let cmd = if cfg!(target_os = "windows") { |
| "echo %FIRST_VAR% %SECOND_VAR%" |
| } else { |
| "echo $FIRST_VAR $SECOND_VAR" |
| }; |
|
|
| let actual = fixture |
| .execute_command( |
| cmd.to_string(), |
| PathBuf::new().join("."), |
| false, |
| Some(vec!["FIRST_VAR".to_string(), "SECOND_VAR".to_string()]), |
| ) |
| .await |
| .unwrap(); |
|
|
| assert!(actual.success()); |
| assert!(actual.stdout.contains("first")); |
| assert!(actual.stdout.contains("second")); |
|
|
| |
| unsafe { |
| std::env::remove_var("FIRST_VAR"); |
| std::env::remove_var("SECOND_VAR"); |
| } |
| } |
|
|
| #[tokio::test] |
| async fn test_command_executor_silent() { |
| let fixture = ForgeCommandExecutorService::new(test_env(), test_printer()); |
| let cmd = "echo 'silent test'"; |
| let dir = "."; |
|
|
| let actual = fixture |
| .execute_command(cmd.to_string(), PathBuf::new().join(dir), true, None) |
| .await |
| .unwrap(); |
|
|
| let mut expected = CommandOutput { |
| stdout: "silent test\n".to_string(), |
| stderr: "".to_string(), |
| command: "echo \"silent test\"".into(), |
| exit_code: Some(0), |
| }; |
|
|
| if cfg!(target_os = "windows") { |
| expected.stdout = format!("'{}'", expected.stdout); |
| } |
|
|
| |
| assert_eq!(actual.stdout.trim(), expected.stdout.trim()); |
| assert_eq!(actual.stderr, expected.stderr); |
| assert_eq!(actual.success(), expected.success()); |
| } |
|
|
| mod write_lossy_utf8 { |
| use pretty_assertions::assert_eq; |
|
|
| use super::super::write_lossy_utf8; |
|
|
| fn run(buf: &[u8]) -> (Vec<u8>, Vec<u8>) { |
| let mut out = Vec::<u8>::new(); |
| let pending = write_lossy_utf8(&mut out, buf).unwrap(); |
| (out, pending) |
| } |
|
|
| #[test] |
| fn valid_ascii_passes_through() { |
| let (out, pending) = run(b"hello"); |
| assert_eq!(out, b"hello"); |
| assert!(pending.is_empty()); |
| } |
|
|
| #[test] |
| fn valid_multibyte_passes_through() { |
| |
| let input = "héllo ✓".as_bytes(); |
| let (out, pending) = run(input); |
| assert_eq!(out, input); |
| assert!(pending.is_empty()); |
| } |
|
|
| #[test] |
| fn incomplete_trailing_codepoint_is_buffered() { |
| |
| let (out, pending) = run(&[b'a', 0xC3]); |
| assert_eq!(out, b"a"); |
| assert_eq!(pending, vec![0xC3]); |
| } |
|
|
| #[test] |
| fn multibyte_split_across_two_chunks_emits_once_whole() { |
| let mut out = Vec::<u8>::new(); |
| let pending = write_lossy_utf8(&mut out, &[b'a', 0xC3]).unwrap(); |
| assert_eq!(pending, vec![0xC3]); |
| assert_eq!(out, b"a"); |
|
|
| let mut working = pending; |
| working.push(0xA9); |
| let pending = write_lossy_utf8(&mut out, &working).unwrap(); |
| assert!(pending.is_empty()); |
| assert_eq!(out, "aé".as_bytes()); |
| } |
|
|
| #[test] |
| fn invalid_byte_in_middle_becomes_replacement() { |
| let (out, pending) = run(&[b'a', 0xFF, b'b']); |
| assert_eq!(out, "a\u{FFFD}b".as_bytes()); |
| assert!(pending.is_empty()); |
| } |
|
|
| #[test] |
| fn lone_continuation_byte_becomes_replacement() { |
| let (out, pending) = run(&[b'a', 0x80, b'b']); |
| assert_eq!(out, "a\u{FFFD}b".as_bytes()); |
| assert!(pending.is_empty()); |
| } |
|
|
| #[test] |
| fn windows_1252_smart_quote_becomes_replacement() { |
| |
| |
| let (out, pending) = run(b"quote: \x91hi\x92"); |
| assert_eq!(out, "quote: \u{FFFD}hi\u{FFFD}".as_bytes()); |
| assert!(pending.is_empty()); |
| } |
| } |
| } |
|
|