From 361eec690f33277ea9fdc56f112c6b3907a19f3e Mon Sep 17 00:00:00 2001 From: Tip ten Brink <75669206+tiptenbrink@users.noreply.github.com> Date: Tue, 30 Apr 2024 18:46:30 +0200 Subject: [PATCH] stdout and stderr working with spinner, extra tests and better lib structure --- Cargo.lock | 125 +++++++++++++++++++++++++++++++- Cargo.toml | 5 +- examples/run/example_input.sh | 7 ++ examples/run/example_spinner.sh | 15 ++++ examples/run/example_stderr.sh | 11 +++ src/commands.rs | 15 ++-- src/lib.rs | 14 ++++ src/main.rs | 16 +--- src/next/api.rs | 49 ++++++------- src/next/process.rs | 125 +++++++++++++++++++++----------- src/next/run.rs | 4 +- src/process.rs | 29 +------- tests/run_tests.rs | 35 +++++++++ 13 files changed, 333 insertions(+), 117 deletions(-) create mode 100755 examples/run/example_input.sh create mode 100755 examples/run/example_spinner.sh create mode 100755 examples/run/example_stderr.sh create mode 100644 src/lib.rs create mode 100644 tests/run_tests.rs diff --git a/Cargo.lock b/Cargo.lock index fb40631..38c34e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "0.6.7" @@ -236,6 +245,18 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "duct" +version = "0.13.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4ab5718d1224b63252cd0c6f74f6480f9ffeb117438a2e0f5cf6d9a4798929c" +dependencies = [ + "libc", + "once_cell", + "os_pipe", + "shared_child", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -351,9 +372,18 @@ dependencies = [ [[package]] name = "log" -version = "0.4.20" +version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" +checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" + +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] [[package]] name = "memchr" @@ -401,6 +431,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "os_pipe" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57119c3b893986491ec9aa85056780d3a0f3cf4da7cc09dd3650dbd6c6738fb9" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "overload" version = "0.1.1" @@ -463,6 +503,50 @@ dependencies = [ "thiserror", ] +[[package]] +name = "regex" +version = "1.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata 0.4.6", + "regex-syntax 0.8.3", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-automata" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax 0.8.3", +] + +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + +[[package]] +name = "regex-syntax" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" + [[package]] name = "relative-path" version = "1.9.2" @@ -574,6 +658,16 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shared_child" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0d94659ad3c2137fef23ae75b03d5241d633f8acded53d672decfa0e6e0caef" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "smallvec" version = "1.11.2" @@ -608,6 +702,27 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "test-log" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b319995299c65d522680decf80f2c108d85b861d81dfe340a10d16cee29d9e6" +dependencies = [ + "test-log-macros", + "tracing-subscriber", +] + +[[package]] +name = "test-log-macros" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8f546451eaa38373f549093fe9fd05e7d2bade739e2ddf834b9968621d60107" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thiserror" version = "1.0.56" @@ -646,12 +761,14 @@ dependencies = [ "clap", "color-eyre", "directories", + "duct", "keyring", "relative-path", "rpassword", "serde", "serde_json", "spinoff", + "test-log", "thiserror", "toml", "tracing", @@ -752,10 +869,14 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" dependencies = [ + "matchers", "nu-ansi-term", + "once_cell", + "regex", "sharded-slab", "smallvec", "thread_local", + "tracing", "tracing-core", "tracing-log", ] diff --git a/Cargo.toml b/Cargo.toml index e3abd67..3e31a24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ description="Simple deployment tool for deploying small applications and loading repository="https://github.com/tiptenbrink/tidploy" readme="README.md" + [dependencies] base64 = "=0.21.7" clap = { version = "=4.4.16", features = ["derive"] } @@ -23,4 +24,6 @@ tracing = "=0.1.40" tracing-subscriber = "=0.3.18" tracing-error = "=0.2.0" directories = "=5.0.1" -color-eyre = "=0.6.3" \ No newline at end of file +color-eyre = "=0.6.3" +test-log = { version="=0.2.15", default-features = false, features = ["trace"] } +duct = "=0.13.7" \ No newline at end of file diff --git a/examples/run/example_input.sh b/examples/run/example_input.sh new file mode 100755 index 0000000..1b11e24 --- /dev/null +++ b/examples/run/example_input.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +# Read a single line of input from stdin +read -r line + +# Print the input line to stdout +echo "You entered: $line" \ No newline at end of file diff --git a/examples/run/example_spinner.sh b/examples/run/example_spinner.sh new file mode 100755 index 0000000..a88ec62 --- /dev/null +++ b/examples/run/example_spinner.sh @@ -0,0 +1,15 @@ +#!/bin/bash +spin[0]="-" +spin[1]="\\" +spin[2]="|" +spin[3]="/" + +j=0 +while [ $j -lt 3 ]; do + for i in "${spin[@]}" + do + echo -ne "\b$i" + sleep 0.1 + done + j=$((j+1)) +done \ No newline at end of file diff --git a/examples/run/example_stderr.sh b/examples/run/example_stderr.sh new file mode 100755 index 0000000..0309bd1 --- /dev/null +++ b/examples/run/example_stderr.sh @@ -0,0 +1,11 @@ +#!/bin/bash +echoerr() { echo "$@" 1>&2; } +echo hello1 +echo hello2 +sleep 2 +echo hello3 +sleep 1 +echoerr hello world2 +echoerr hello world3 +sleep 1 +echoerr hello world1 \ No newline at end of file diff --git a/src/commands.rs b/src/commands.rs index 4218fe9..904fedf 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,4 +1,5 @@ use std::path::{Path, PathBuf}; +use std::process::ExitCode; use crate::archives::{extract_archive, make_archive}; use crate::errors::{ProcessError, RepoError}; @@ -236,7 +237,7 @@ fn prepare_command( Ok(Some(state)) } -pub(crate) fn run_cli() -> Result<(), Report> { +pub fn run_cli() -> Result { // We get our CLI arguments using the clap crate. This allows us to state all our arguments using // a set of structs, indicating the structure of our commands // Note that it uses the Cargo.toml description as the main help command description @@ -260,14 +261,14 @@ pub(crate) fn run_cli() -> Result<(), Report> { secret_command(&state, key).map_err(ErrorRepr::Auth)?; - Ok(()) + Ok(ExitCode::SUCCESS) } Commands::Download { repo_only } => { let state = create_state_create(cli_state.clone(), None, None, false) .map_err(ErrorRepr::Load)?; download_command(cli_state, state.repo, repo_only)?; - Ok(()) + Ok(ExitCode::SUCCESS) } Commands::Deploy { executable, @@ -314,14 +315,18 @@ pub(crate) fn run_cli() -> Result<(), Report> { run_entrypoint(state.deploy_dir(), &state.exe_name, state.envs) .map_err(ErrorRepr::Exe)?; - Ok(()) + Ok(ExitCode::SUCCESS) } Commands::Run { executable, variables, archive, } => { - run_command(cli_state, executable, variables, archive) + let out = run_command(cli_state, executable, variables, archive)?; + // If [process::ExitCode::from_raw] gets stabilized this can be simplified + let code = u8::try_from(out.exit.code().unwrap_or(0))?; + + Ok(ExitCode::from(code)) } } } diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..dd8bae3 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,14 @@ +mod next; +mod archives; +mod config; +mod errors; +mod filesystem; +mod git; +mod process; +mod secret; +mod secret_store; +mod state; + +pub mod commands; + +pub use next::api::*; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 5337b1b..a7412f6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,6 @@ -mod archives; -mod commands; -mod config; -mod errors; -mod filesystem; -mod git; -mod next; -mod process; -mod secret; -mod secret_store; -mod state; +use std::process::ExitCode; + +use tidploy::commands; use color_eyre::eyre::Report; use tracing_error::ErrorLayer; @@ -25,7 +17,7 @@ fn install_tracing() { .init(); } -fn main() -> Result<(), Report> { +fn main() -> Result { install_tracing(); color_eyre::install()?; diff --git a/src/next/api.rs b/src/next/api.rs index 581ad50..d5c0bd3 100644 --- a/src/next/api.rs +++ b/src/next/api.rs @@ -1,9 +1,25 @@ -use super::run::{run_command as inner_run_command}; +use super::run::run_command as inner_run_command; use crate::state::CliEnvState; -pub use crate::state::StateContext; use color_eyre::eyre::Report; use thiserror::Error as ThisError; +pub use super::process::EntrypointOut; +pub use crate::state::StateContext; + +/// These represent global arguments that correspond to global args of the CLI (i.e. valid for all +/// subcomannds). To limit breaking changes, this struct is `non_exhaustive`. +/// +/// Instantiate GlobalArguments using: +/// ``` +/// # use tidploy::GlobalArguments; +/// let mut global_args = GlobalArguments::default(); +/// ``` +/// Then you can set the arguments like: +/// ``` +/// # use tidploy::GlobalArguments; +/// # let mut global_args = GlobalArguments::default(); +/// global_args.deploy_path = Some("use/deploy".to_owned()); +/// ``` #[non_exhaustive] pub struct GlobalArguments { pub context: Option, @@ -23,17 +39,8 @@ impl Default for GlobalArguments { } } -impl GlobalArguments { - pub fn cli_env(context: Option, repo_url: Option, deploy_path: Option, tag: Option) -> Self { - GlobalArguments { - context, - repo_url, - deploy_path, - tag - } - } -} - +/// These represent arguments that correspond to args of the CLI `run` subcommand. To limit breaking +/// changes, this struct is `non_exhaustive`. See [GlobalArguments] for details on how to instantiate. #[non_exhaustive] pub struct RunArguments { pub executable: Option, @@ -51,16 +58,6 @@ impl Default for RunArguments { } } -impl RunArguments { - pub fn with(executable: Option, variables: Vec, archive: Option) -> Self { - RunArguments { - executable, - variables, - archive - } - } -} - impl From for CliEnvState { fn from(args: GlobalArguments) -> Self { CliEnvState { @@ -72,14 +69,16 @@ impl From for CliEnvState { } } +/// Simple wrapper error that displays the inner `eyre` [Report]. However, it is not directly accessible. Do +/// not try to match on its potential errors, simply directly display it. #[derive(ThisError, Debug)] #[error("{msg} {source}")] pub struct CommandError { - msg: String, + pub msg: String, source: Report } -pub fn run_command(global_args: GlobalArguments, args: RunArguments) -> Result<(), CommandError> { +pub fn run_command(global_args: GlobalArguments, args: RunArguments) -> Result { inner_run_command(global_args.into(), args.executable, args.variables, args.archive).map_err(|e| CommandError { msg: "An error occurred in the inner application layer.".to_owned(), diff --git a/src/next/process.rs b/src/next/process.rs index 03be5da..636f93e 100644 --- a/src/next/process.rs +++ b/src/next/process.rs @@ -1,5 +1,10 @@ use color_eyre::eyre::{Context, ContextCompat, Report}; +use std::io::{stderr, stdout, Read, Write}; +use std::process::ExitStatus; use std::str; +use duct::cmd; +use std::thread::sleep; +use std::time::Duration; /// This is purely application-level code, hence you would never want to reference it as a library. /// For this reason we do not really care about the exact errors and need not match on them. use std::{ @@ -22,13 +27,23 @@ fn process_out(bytes: Vec) -> Result { Ok(output_string) } +pub struct EntrypointOut { + pub out: String, + pub exit: ExitStatus +} + pub(crate) fn run_entrypoint>( entrypoint_dir: P, entrypoint: &str, envs: HashMap, -) -> Result<(), Report> { +) -> Result { println!("Running {}!", &entrypoint); + + let program_path = entrypoint_dir.as_ref().join(entrypoint); + // let cmd_expr = cmd(&program_path, Vec::::new()).dir(entrypoint_dir.as_ref()).full_env(&envs); + // let reader = cmd_expr.stderr_to_stdout().reader()?; + let entry_span = span!(Level::DEBUG, "entrypoint", path = program_path.to_str()); let _enter = entry_span.enter(); @@ -37,58 +52,84 @@ pub(crate) fn run_entrypoint>( .current_dir(&entrypoint_dir) .envs(&envs) .stdout(Stdio::piped()) + .stderr(Stdio::piped()) .spawn() .wrap_err("System IO error occurred spawning process!")?; let entrypoint_stdout = entrypoint_output .stdout .take() - .wrap_err("No output for process!")?; - - let reader = BufReader::new(entrypoint_stdout); - - reader - .lines() - .map_while(Result::ok) - .for_each(|line| println!("{}", line)); - - let output_stderr = entrypoint_output - .wait_with_output() - .wrap_err("Error reading output stderr!")? - .stderr; - if !output_stderr.is_empty() { - println!( - "Entrypoint {:?} failed with error: {}", - program_path.as_path(), - process_out(output_stderr)? - ) - } - Ok(()) + .wrap_err("Error getting process stdout!")?; + + let entrypoint_stderr = entrypoint_output + .stderr + .take() + .wrap_err("Error getting process stderr!")?; + + let mut out: String = String::with_capacity(128); + + let mut reader = BufReader::new(entrypoint_stdout); + let mut reader_err = BufReader::new(entrypoint_stderr); + + let mut buffer_out = [0; 32]; + let mut buffer_err = [0; 32]; + + let exit = loop { + let bytes_read_out = reader.read(&mut buffer_out).wrap_err("Error reading stdout bytes!")?; + let bytes_read_err = reader_err.read(&mut buffer_err).wrap_err("Error reading stdout bytes!")?; + + if bytes_read_out > 0 { + let string_buf = str::from_utf8(&buffer_out[..bytes_read_out]).wrap_err("Error converting stdout bytes to UTF-8!")?; + print!("{}", string_buf); + // This flush is important in case the script only writes a few characters + // Like in the case of a progress bar or spinner + let _ = stdout().flush(); + out.push_str(string_buf); + } + if bytes_read_err > 0 { + let string_buf = str::from_utf8(&buffer_err[..bytes_read_err]).wrap_err("Error converting stderr bytes to UTF-8!")?; + print!("{}", string_buf); + let _ = stderr().flush(); + out.push_str(string_buf); + } + + if bytes_read_out == 0 && bytes_read_err == 0 { + let exit = entrypoint_output.try_wait().wrap_err("Error attempting to read entrypoint exit status!")?; + if let Some(exit) = exit { + break exit + } + } + }; + + Ok(EntrypointOut { + out, + exit + }) } -#[cfg(test)] -mod tests { - use std::env; +// #[cfg(test)] +// mod tests { +// use std::env; - use crate::git::git_root_dir; +// use crate::git::git_root_dir; - use super::*; +// use super::*; - #[test] - fn test_run_entrypoint() { - let current_dir = env::current_dir().unwrap(); - let project_dir = git_root_dir(¤t_dir).unwrap(); - let project_path = Path::new(&project_dir).join("examples").join("run"); +// #[test] +// fn test_run_entrypoint() { +// let current_dir = env::current_dir().unwrap(); +// let project_dir = git_root_dir(¤t_dir).unwrap(); +// let project_path = Path::new(&project_dir).join("examples").join("run"); - run_entrypoint(project_path, "do_echo.sh", HashMap::new()).unwrap(); - } +// run_entrypoint(project_path, "do_echo.sh", HashMap::new()).unwrap(); +// } - #[test] - fn test_spawn() { - let current_dir = env::current_dir().unwrap(); - let project_dir = git_root_dir(¤t_dir).unwrap(); - let project_path = Path::new(&project_dir).join("examples").join("run"); +// #[test] +// fn test_spawn() { +// let current_dir = env::current_dir().unwrap(); +// let project_dir = git_root_dir(¤t_dir).unwrap(); +// let project_path = Path::new(&project_dir).join("examples").join("run"); - run_entrypoint(project_path, "do_echo.sh", HashMap::new()).unwrap(); - } -} +// run_entrypoint(project_path, "do_echo.sh", HashMap::new()).unwrap(); +// } +// } diff --git a/src/next/run.rs b/src/next/run.rs index 24183af..127da2c 100644 --- a/src/next/run.rs +++ b/src/next/run.rs @@ -5,10 +5,10 @@ use tracing::{debug, instrument}; use crate::{archives::extract_archive, filesystem::get_dirs, state::{create_state_create, extra_envs, CliEnvState}}; -use super::{process::run_entrypoint, state::create_state_run}; +use super::{process::{run_entrypoint, EntrypointOut}, state::create_state_run}; #[instrument(name = "run", level = "debug", skip_all)] -pub(crate) fn run_command(cli_state: CliEnvState, executable: Option, variables: Vec, archive: Option) -> Result<(), Report> { +pub(crate) fn run_command(cli_state: CliEnvState, executable: Option, variables: Vec, archive: Option) -> Result { // Only loads archive if it is given, otherwise path is None // let state = if let Some(archive) = archive { // let cache_dir = get_dirs().cache.as_path(); diff --git a/src/process.rs b/src/process.rs index 06477f4..a4b51ab 100644 --- a/src/process.rs +++ b/src/process.rs @@ -68,31 +68,4 @@ pub(crate) fn run_entrypoint>( ) } Ok(()) -} - -#[cfg(test)] -mod tests { - use std::env; - - use crate::git::git_root_dir; - - use super::*; - - #[test] - fn test_run_entrypoint() { - let current_dir = env::current_dir().unwrap(); - let project_dir = git_root_dir(¤t_dir).unwrap(); - let project_path = Path::new(&project_dir).join("examples").join("run"); - - run_entrypoint(project_path, "do_echo.sh", HashMap::new()).unwrap(); - } - - #[test] - fn test_spawn() { - let current_dir = env::current_dir().unwrap(); - let project_dir = git_root_dir(¤t_dir).unwrap(); - let project_path = Path::new(&project_dir).join("examples").join("run"); - - run_entrypoint(project_path, "do_echo.sh", HashMap::new()).unwrap(); - } -} +} \ No newline at end of file diff --git a/tests/run_tests.rs b/tests/run_tests.rs new file mode 100644 index 0000000..aad759c --- /dev/null +++ b/tests/run_tests.rs @@ -0,0 +1,35 @@ +use test_log::test; + +use tidploy::{run_command, CommandError, GlobalArguments, RunArguments, StateContext}; + +#[test] +fn test_run() -> Result<(), CommandError> { + let mut global_args = GlobalArguments::default(); + let mut args = RunArguments::default(); + global_args.context = Some(StateContext::None); + args.executable = Some("examples/run/example_echo.sh".to_owned()); + + let output = run_command(global_args, args)?; + assert!(output.exit.success()); + + let success_str = "Success!".to_owned(); + assert_eq!(output.out.trim(), success_str); + + Ok(()) +} + +#[test] +fn test_spinner() -> Result<(), CommandError> { + let mut global_args = GlobalArguments::default(); + let mut args = RunArguments::default(); + global_args.context = Some(StateContext::None); + args.executable = Some("examples/run/example_spinner.sh".to_owned()); + + let output = run_command(global_args, args)?; + assert!(output.exit.success()); + + // \u{8} is backspace, the final rendered output is only '/' + assert_eq!("\u{8}-\u{8}\\\u{8}|\u{8}/\u{8}-\u{8}\\\u{8}|\u{8}/\u{8}-\u{8}\\\u{8}|\u{8}/", output.out); + + Ok(()) +} \ No newline at end of file