diff --git a/build.roc b/build.roc index 4c03c147..a38eb01a 100755 --- a/build.roc +++ b/build.roc @@ -47,7 +47,6 @@ roc_version! = |roc_cmd| info!("Checking provided roc; executing `${roc_cmd} version`:")? Cmd.exec!(roc_cmd, ["version"]) - |> Result.map_err(RocVersionCheckFailed) get_os_and_arch! : {} => Result OSAndArch _ get_os_and_arch! = |{}| @@ -78,7 +77,6 @@ build_stub_app_lib! = |roc_cmd, stub_lib_path| info!("Building stubbed app shared library ...")? Cmd.exec!(roc_cmd, ["build", "--lib", "platform/libapp.roc", "--output", stub_lib_path, "--optimize"]) - |> Result.map_err(ErrBuildingAppStub) stub_file_extension : OSAndArch -> Str stub_file_extension = |os_and_arch| @@ -130,7 +128,6 @@ cargo_build_host! = |debug_mode| args = cargo_build_args!({})? Cmd.exec!("cargo", args) - |> Result.map_err(ErrBuildingHostBinaries) copy_host_lib! : OSAndArch, Str => Result {} _ copy_host_lib! = |os_and_arch, rust_target_folder| @@ -142,7 +139,6 @@ copy_host_lib! = |os_and_arch, rust_target_folder| info!("Moving the prebuilt binary from ${host_build_path} to ${host_dest_path} ...")? Cmd.exec!("cp", [host_build_path, host_dest_path]) - |> Result.map_err(ErrMovingPrebuiltLegacyBinary) preprocess_host! : Str, Str, Str => Result {} _ preprocess_host! = |roc_cmd, stub_lib_path, rust_target_folder| @@ -152,7 +148,6 @@ preprocess_host! = |roc_cmd, stub_lib_path, rust_target_folder| surgical_build_path = "${rust_target_folder}host" Cmd.exec!(roc_cmd, ["preprocess-host", surgical_build_path, "platform/main.roc", stub_lib_path]) - |> Result.map_err(ErrPreprocessingSurgicalBinary) info! : Str => Result {} _ info! = |msg| diff --git a/ci/check_all_exposed_funs_tested.roc b/ci/check_all_exposed_funs_tested.roc index 7441fa1a..226fabe6 100644 --- a/ci/check_all_exposed_funs_tested.roc +++ b/ci/check_all_exposed_funs_tested.roc @@ -106,39 +106,29 @@ is_function_unused! = |module_name, function_name| )? # Check if ripgrep is installed - rg_check_cmd = Cmd.new("rg") |> Cmd.arg("--version") - rg_check_output = Cmd.output!(rg_check_cmd) - - when rg_check_output.status is - Ok(0) -> - unused_in_dir = - search_dirs - |> List.map_try!( |search_dir| - # Skip searching if directory doesn't exist - dir_exists = File.is_dir!(search_dir)? - if !dir_exists then - Ok(Bool.true) # Consider unused if we can't search - else - # Use ripgrep to search for the function pattern - cmd = - Cmd.new("rg") - |> Cmd.arg("-q") # Quiet mode - we only care about exit code - |> Cmd.arg(function_pattern) - |> Cmd.arg(search_dir) - - status_res = Cmd.status!(cmd) - - # ripgrep returns status 0 if matches were found, 1 if no matches - when status_res is - Ok(0) -> Ok(Bool.false) # Function is used (not unused) - _ -> Ok(Bool.true) - )? - - unused_in_dir - |> List.walk!(Bool.true, |state, is_unused_res| state && is_unused_res) - |> Ok - _ -> - err_s("Error: ripgrep (rg) is not installed or not available in PATH. Please install ripgrep to use this script. Full output: ${Inspect.to_str(rg_check_output)}") + _ = Cmd.exec!("rg", ["--version"]) ? |err| RipgrepNotInstalled(err) + + + unused_in_dir = + search_dirs + |> List.map_try!( |search_dir| + # Skip searching if directory doesn't exist + dir_exists = File.is_dir!(search_dir)? + if !dir_exists then + Ok(Bool.true) # Consider unused if we can't search + else + # Use ripgrep to search for the function pattern + cmd_res = + Cmd.exec!("rg", ["-q", function_pattern, search_dir]) + + when cmd_res is + Ok(_) -> Ok(Bool.false) # Function is used (not unused) + _ -> Ok(Bool.true) + )? + + unused_in_dir + |> List.walk!(Bool.true, |state, is_unused_res| state && is_unused_res) + |> Ok diff --git a/ci/expect_scripts/cmd-test.exp b/ci/expect_scripts/cmd-test.exp new file mode 100644 index 00000000..23fee859 --- /dev/null +++ b/ci/expect_scripts/cmd-test.exp @@ -0,0 +1,27 @@ +#!/usr/bin/expect + +# uncomment line below for debugging +# exp_internal 1 + +set timeout 7 + +source ./ci/expect_scripts/shared-code.exp + +spawn $env(TESTS_DIR)cmd-test + + +set expected_output [normalize_output { +cat: non_existent.txt: No such file or directory +cat: non_existent.txt: No such file or directory +cat: non_existent.txt: No such file or directory +All tests passed. +}] + +expect $expected_output { + expect eof { + check_exit_and_segfault + } +} + +puts stderr "\nExpect script failed: output was not as expected. Diff the output with expected_output in this script. Alternatively, uncomment `exp_internal 1` to debug." +exit 1 \ No newline at end of file diff --git a/ci/expect_scripts/command.exp b/ci/expect_scripts/command.exp index d5c17758..59df35fb 100644 --- a/ci/expect_scripts/command.exp +++ b/ci/expect_scripts/command.exp @@ -12,13 +12,14 @@ spawn $env(EXAMPLES_DIR)command set expected_output [normalize_output { Hello -Command output: Hi - -Command output: BAZ=DUCK +\{stderr_utf8_lossy: "", stdout_utf8: "Hi +"\} +BAZ=DUCK FOO=BAR XYZ=ABC - -Yo +cat: non_existent.txt: No such file or directory +Exit code: 1 +\{stderr_bytes: \[\], stdout_bytes: \[72, 105, 10\]\} }] expect $expected_output { diff --git a/ci/expect_scripts/path-test.exp b/ci/expect_scripts/path-test.exp index 28cd9290..79f20dfe 100644 --- a/ci/expect_scripts/path-test.exp +++ b/ci/expect_scripts/path-test.exp @@ -24,7 +24,6 @@ Path with replaced extension: test_file.new Extension replaced: Bool.true Testing Path file operations: -test_path_bytes.txt exists: Bool.true Bytes written: \\\[72, 101, 108, 108, 111, 44, 32, 80, 97, 116, 104, 33\\\] Bytes read: \\\[72, 101, 108, 108, 111, 44, 32, 80, 97, 116, 104, 33\\\] Bytes match: Bool.true @@ -35,13 +34,9 @@ UTF-8 content matches: Bool.true JSON content: {\"message\":\"Path test\",\"numbers\":\\\[1,2,3\\\]} JSON contains 'message' field: Bool.true JSON contains 'numbers' field: Bool.true -File exists before delete: Bool.true -File exists after delete: Bool.false - -Testing Path directory operations: -Created directory: drwxr-xr-x \\d+ \\w+ (\\w+ )? *\\d+ \\w+ +\\d+ \\d+:\\d+ test_single_dir -Is a directory: Bool.true +File no longer exists: Bool.true +Testing Path directory operations... Nested directory structure: test_parent test_parent/test_child @@ -56,11 +51,10 @@ dr\[-rwx\]+ +\\d+ \\w+ (\\w+ )? *\\d+ \\w+ +\\d+ \\d+:\\d+ \\.\\. -\[-rwx\]+ +\\d+ \\w+ (\\w+ )? *\\d+ \\w+ +\\d+ \\d+:\\d+ file2\\.txt dr\[-rwx\]+ +\\d+ \\w+ (\\w+ )? *\\d+ \\w+ +\\d+ \\d+:\\d+ subdir -Empty dir exists before delete: Bool.true -Empty dir exists after delete: Bool.false +Empty dir was deleted: Bool.true Size before delete_all: \\d+\\w*\\s*test_parent -Parent dir exists after delete_all: Bool.false +Parent dir no longer exists: Bool.true Testing Path.hard_link!: Hard link count before: 1 @@ -96,7 +90,13 @@ Files to clean up: -rw-r--r-- \\d+ \\w+ \\w+ \\d+ \\w+ +\\d+ \\d+:\\d+ test_path_rename_new\\.txt -rw-r--r-- \\d+ \\w+ \\w+ \\d+ \\w+ +\\d+ \\d+:\\d+ test_path_utf8\\.txt -Files remaining after cleanup: Bool.false +ls: cannot access .* +ls: cannot access .* +ls: cannot access .* +ls: cannot access .* +ls: cannot access .* +ls: cannot access .* +Files deleted successfully: Bool.true "] expect -re $expected_output { diff --git a/crates/roc_command/src/lib.rs b/crates/roc_command/src/lib.rs index e167d266..00241880 100644 --- a/crates/roc_command/src/lib.rs +++ b/crates/roc_command/src/lib.rs @@ -1,4 +1,5 @@ //! This crate provides common functionality for Roc to interface with `std::process::Command` + use roc_std::{RocList, RocResult, RocStr}; #[derive(Clone, Debug, PartialEq, PartialOrd, Eq, Ord, Hash)] @@ -61,29 +62,50 @@ impl From<&Command> for std::process::Command { #[derive(Clone, Debug, PartialEq, PartialOrd, Eq, Ord, Hash)] #[repr(C)] -pub struct OutputFromHost { - pub status: roc_std::RocResult, - pub stderr: roc_std::RocList, - pub stdout: roc_std::RocList, +pub struct OutputFromHostSuccess { + pub stderr_bytes: roc_std::RocList, + pub stdout_bytes: roc_std::RocList, +} + +#[derive(Clone, Debug, PartialEq, PartialOrd, Eq, Ord, Hash)] +#[repr(C)] +pub struct OutputFromHostFailure { + pub stderr_bytes: roc_std::RocList, + pub stdout_bytes: roc_std::RocList, + pub exit_code: i32, } -impl roc_std::RocRefcounted for OutputFromHost { +impl roc_std::RocRefcounted for OutputFromHostSuccess { fn inc(&mut self) { - self.status.inc(); - self.stderr.inc(); - self.stdout.inc(); + self.stdout_bytes.inc(); + self.stderr_bytes.inc(); } fn dec(&mut self) { - self.status.dec(); - self.stderr.dec(); - self.stdout.dec(); + self.stdout_bytes.dec(); + self.stderr_bytes.dec(); } fn is_refcounted() -> bool { true } } -pub fn command_status(roc_cmd: &Command) -> RocResult { +impl roc_std::RocRefcounted for OutputFromHostFailure { + fn inc(&mut self) { + self.exit_code.inc(); + self.stdout_bytes.inc(); + self.stderr_bytes.inc(); + } + fn dec(&mut self) { + self.exit_code.dec(); + self.stdout_bytes.dec(); + self.stderr_bytes.dec(); + } + fn is_refcounted() -> bool { + true + } +} + +pub fn command_exec_exit_code(roc_cmd: &Command) -> RocResult { match std::process::Command::from(roc_cmd).status() { Ok(status) => from_exit_status(status), Err(err) => RocResult::err(err.into()), @@ -94,29 +116,44 @@ pub fn command_status(roc_cmd: &Command) -> RocResult fn from_exit_status(status: std::process::ExitStatus) -> RocResult { match status.code() { Some(code) => RocResult::ok(code), - None => killed_by_signal(), + None => RocResult::err(killed_by_signal_err()), } } -// If no exit code is returned, the process was terminated by a signal. -fn killed_by_signal() -> RocResult { - RocResult::err(roc_io_error::IOErr { +fn killed_by_signal_err() -> roc_io_error::IOErr { + roc_io_error::IOErr { tag: roc_io_error::IOErrTag::Other, - msg: "Killed by signal".into(), - }) + msg: "Process was killed by operating system signal.".into(), + } } -pub fn command_output(roc_cmd: &Command) -> OutputFromHost { +// TODO Can we make this return a tag union (with three variants) ? +pub fn command_exec_output(roc_cmd: &Command) -> RocResult> { match std::process::Command::from(roc_cmd).output() { - Ok(output) => OutputFromHost { - status: from_exit_status(output.status), - stdout: RocList::from(&output.stdout[..]), - stderr: RocList::from(&output.stderr[..]), - }, - Err(err) => OutputFromHost { - status: RocResult::err(err.into()), - stdout: RocList::empty(), - stderr: RocList::empty(), - }, + Ok(output) => + match output.status.code() { + Some(status) => { + + let stdout_bytes = RocList::from(&output.stdout[..]); + let stderr_bytes = RocList::from(&output.stderr[..]); + + if status == 0 { + // Success case + RocResult::ok(OutputFromHostSuccess { + stderr_bytes, + stdout_bytes, + }) + } else { + // Failure case + RocResult::err(RocResult::ok(OutputFromHostFailure { + stderr_bytes, + stdout_bytes, + exit_code: status, + })) + } + }, + None => RocResult::err(RocResult::err(killed_by_signal_err())) + } + Err(err) => RocResult::err(RocResult::err(err.into())) } } diff --git a/crates/roc_host/src/lib.rs b/crates/roc_host/src/lib.rs index 65fd5312..8409b017 100644 --- a/crates/roc_host/src/lib.rs +++ b/crates/roc_host/src/lib.rs @@ -339,8 +339,8 @@ pub fn init() { roc_fx_tcp_read_exactly as _, roc_fx_tcp_read_until as _, roc_fx_tcp_write as _, - roc_fx_command_status as _, - roc_fx_command_output as _, + roc_fx_command_exec_exit_code as _, + roc_fx_command_exec_output as _, roc_fx_dir_create as _, roc_fx_dir_create_all as _, roc_fx_dir_delete_empty as _, @@ -742,17 +742,17 @@ pub extern "C" fn roc_fx_tcp_write(stream: RocBox<()>, msg: &RocList) -> Roc } #[no_mangle] -pub extern "C" fn roc_fx_command_status( +pub extern "C" fn roc_fx_command_exec_exit_code( roc_cmd: &roc_command::Command, ) -> RocResult { - roc_command::command_status(roc_cmd) + roc_command::command_exec_exit_code(roc_cmd) } #[no_mangle] -pub extern "C" fn roc_fx_command_output( +pub extern "C" fn roc_fx_command_exec_output( roc_cmd: &roc_command::Command, -) -> roc_command::OutputFromHost { - roc_command::command_output(roc_cmd) +) -> RocResult> { + roc_command::command_exec_output(roc_cmd) } #[no_mangle] diff --git a/examples/command.roc b/examples/command.roc index 33deb9a7..1aaa362b 100644 --- a/examples/command.roc +++ b/examples/command.roc @@ -1,7 +1,6 @@ app [main!] { pf: platform "../platform/main.roc" } import pf.Stdout -import pf.Stderr import pf.Cmd import pf.Arg exposing [Arg] @@ -13,84 +12,40 @@ main! : List Arg => Result {} _ main! = |_args| # Simplest way to execute a command (prints to your terminal). - Cmd.exec!("echo", ["Hello"]) ? |err| EchoHelloFailed(err) - - # To execute and capture the output (stdout, stderr, and exit code) without inheriting your terminal. - output_example!({}) ? |err| OutputExampleFailed(err) - - # To run a command with an environment variable. - env_example!({}) ? |err| EnvExampleFailed(err) - - # To execute and just get the exit code (prints to your terminal). - status_example!({}) ? |err| StatusExampleFailed(err) - - Ok({}) - -# Execute command and capture the output (stdout, stderr, and exit code) -output_example! : {} => Result {} _ -output_example! = |{}| + Cmd.exec!("echo", ["Hello"])? + # To execute and capture the output (stdout and stderr) without inheriting your terminal. cmd_output = Cmd.new("echo") |> Cmd.args(["Hi"]) - |> Cmd.output! - - print_output!(cmd_output) - - -print_output! : Cmd.Output => Result {} _ -print_output! = |cmd_output| + |> Cmd.exec_output!()? + Stdout.line!("${Inspect.to_str(cmd_output)}")? - when cmd_output.status is - Ok(0) -> - stdout_utf8 = Str.from_utf8(cmd_output.stdout)? - Stdout.line!("Command output: ${stdout_utf8}") + # To run a command with environment variables. + Cmd.new("env") + |> Cmd.clear_envs # You probably don't need to clear all other environment variables, this is just an example. + |> Cmd.env("FOO", "BAR") + |> Cmd.envs([("BAZ", "DUCK"), ("XYZ", "ABC")]) # Set multiple environment variables at once with `envs` + |> Cmd.args(["-v"]) + |> Cmd.exec_cmd!()? - Ok(exit_code) -> - stdout_utf8 = Str.from_utf8_lossy(cmd_output.stdout) - stderr_utf8 = Str.from_utf8_lossy(cmd_output.stderr) - err_data = - """ - Command failed: - - exit code: ${Num.to_str(exit_code)} - - stdout: ${stdout_utf8} - - stderr: ${stderr_utf8} - """ - - Stderr.line!(err_data) - - Err(err) -> - Stderr.line!("Failed to get exit code for command, error: ${Inspect.to_str(err)}") - - -# Run command with an environment variable -env_example! : {} => Result {} _ -env_example! = |{}| - - cmd_output = - Cmd.new("env") - |> Cmd.clear_envs # You probably don't need to clear all other environment variables, this is just an example. - |> Cmd.env("FOO", "BAR") - |> Cmd.envs([("BAZ", "DUCK"), ("XYZ", "ABC")]) # Set multiple environment variables at once with `envs` - |> Cmd.args(["-v"]) - |> Cmd.output! + # To execute and just get the exit code (prints to your terminal). + # Prefer using `exec!` or `exec_cmd!`. + exit_code = + Cmd.new("cat") + |> Cmd.args(["non_existent.txt"]) + |> Cmd.exec_exit_code!()? - print_output!(cmd_output) + Stdout.line!("Exit code: ${Num.to_str(exit_code)}")? -# Execute command and capture the exit code -status_example! : {} => Result {} _ -status_example! = |{}| - cmd_result = + # To execute and capture the output (stdout and stderr) in the original form as bytes without inheriting your terminal. + # Prefer using `exec_output!`. + cmd_output_bytes = Cmd.new("echo") - |> Cmd.args(["Yo"]) - |> Cmd.status! - - when cmd_result is - Ok(0) -> Ok({}) + |> Cmd.args(["Hi"]) + |> Cmd.exec_output_bytes!()? - Ok(exit_code) -> - Stderr.line!("Command failed with exit code: ${Num.to_str(exit_code)}") + Stdout.line!("${Inspect.to_str(cmd_output_bytes)}")? - Err(err) -> - Stderr.line!("Failed to get exit code for command, error: ${Inspect.to_str(err)}") + Ok({}) diff --git a/flake.lock b/flake.lock index d458172b..6aeacff7 100644 --- a/flake.lock +++ b/flake.lock @@ -76,11 +76,11 @@ "rust-overlay": "rust-overlay" }, "locked": { - "lastModified": 1751094619, - "narHash": "sha256-yJ02jr3n7IJ6vcZYBKpOmiakoVDd6gBFz34fCCtB9yE=", + "lastModified": 1755013606, + "narHash": "sha256-aV/tgfzIP/4ohgxjf/eO6UJBmQwBdpPU3vqjs6GDFQI=", "owner": "roc-lang", "repo": "roc", - "rev": "3e3e6107edb078cf02eda80655197a0d9fdc8b44", + "rev": "16fb4725a0ed8483637176c4020c2cb282081c7a", "type": "github" }, "original": { @@ -128,11 +128,11 @@ ] }, "locked": { - "lastModified": 1751078221, - "narHash": "sha256-/SRmXIPxL7ixFLZgcDdgZDuIwt8eWQAamMYer0ODwbM=", + "lastModified": 1754966322, + "narHash": "sha256-7f/LH60DnjjQVKbXAsHIniGaU7ixVM7eWU3hyjT24YI=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "1712a6d3430ca75353d366b7ddd1c79d6b243efc", + "rev": "7c13cec2e3828d964b9980d0ffd680bd8d4dce90", "type": "github" }, "original": { diff --git a/platform/Cmd.roc b/platform/Cmd.roc index e2f05fb2..fa152f86 100644 --- a/platform/Cmd.roc +++ b/platform/Cmd.roc @@ -1,85 +1,164 @@ module [ Cmd, - Output, new, arg, args, env, envs, clear_envs, - status!, - output!, + exec_output!, + exec_output_bytes!, exec!, + exec_cmd!, + exec_exit_code!, ] -import InternalCmd -import InternalIOErr +import InternalCmd exposing [to_str] +import InternalIOErr exposing [IOErr] import Host -## Represents a command to be executed in a child process. -Cmd := InternalCmd.Command +## Simplest way to execute a command while inheriting stdin, stdout and stderr from parent. +## If you want to capture the output, use [exec_output!] instead. +## ``` +## # Call echo to print "hello world" +## Cmd.exec!("echo", ["hello world"])? +## ``` +exec! : Str, List Str => Result {} [ExecFailed { command : Str, exit_code : I32 }, FailedToGetExitCode { command : Str, err : IOErr }] +exec! = |cmd_name, arguments| + exit_code = + new(cmd_name) + |> args(arguments) + |> exec_exit_code!()? + + if exit_code == 0i32 then + Ok({}) + else + command = "${cmd_name} ${Str.join_with(arguments, " ")}" + Err(ExecFailed({ command, exit_code })) + +## Execute a Cmd while inheriting stdin, stdout and stderr from parent. +## You should prefer using [exec!] instead, only use this if you want to use [env], [envs] or [clear_envs]. +## If you want to capture the output, use [exec_output!] instead. +## ``` +## # Execute `cargo build` with env var. +## Cmd.new("cargo") +## |> Cmd.arg("build") +## |> Cmd.env("RUST_BACKTRACE", "1") +## |> Cmd.exec_cmd!()? +## ``` +exec_cmd! : Cmd => Result {} [ExecCmdFailed { command : Str, exit_code : I32 }, FailedToGetExitCode { command : Str, err : IOErr }] +exec_cmd! = |@Cmd(cmd)| + exit_code = + exec_exit_code!(@Cmd(cmd))? -## Represents the output of a command. + if exit_code == 0i32 then + Ok({}) + else + Err(ExecCmdFailed({ command: to_str(cmd), exit_code })) + +## Execute command and capture stdout and stderr. +## +## > Stdin is not inherited from the parent and any attempt by the child process +## > to read from the stdin stream will result in the stream immediately closing. +## +## Use [exec_output_bytes!] instead if you want to capture the output in the original form as bytes. +## [exec_output_bytes!] may also be used for maximum performance, because you may be able to avoid unnecessary UTF-8 conversions. ## -## Output is a record: ## ``` -## { -## status : Result I32 InternalIOErr.IOErr, -## stdout : List U8, -## stderr : List U8, -## } +## cmd_output = +## Cmd.new("echo") +## |> Cmd.args(["Hi"]) +## |> Cmd.exec_output!()? +## +## Stdout.line!("Echo output: ${cmd_output.stdout_utf8}")? ## ``` ## -Output : InternalCmd.Output +exec_output! : + Cmd + => + Result + { stdout_utf8 : Str, stderr_utf8_lossy : Str } + [ + StdoutContainsInvalidUtf8 { cmd_str : Str, err : [BadUtf8 { index : U64, problem : Str.Utf8Problem }] }, + NonZeroExitCode { command : Str, exit_code : I32, stdout_utf8_lossy : Str, stderr_utf8_lossy : Str }, + FailedToGetExitCode { command : Str, err : IOErr }, + ] +exec_output! = |@Cmd(cmd)| + exec_res = Host.command_exec_output!(cmd) -# This hits a compiler bug: Alias `6.IdentId(11)` not registered in delayed aliases! ... -# ## Converts output into a utf8 string. Invalid utf8 sequences in stderr are ignored. -# to_str : Output -> Result Str [BadUtf8 { index : U64, problem : Str.Utf8Problem }] -# to_str = |output| -# InternalCmd.output_to_str(output) + when exec_res is + Ok({ stderr_bytes, stdout_bytes }) -> + stdout_utf8 = Str.from_utf8(stdout_bytes) ? |err| StdoutContainsInvalidUtf8({ cmd_str: to_str(cmd), err }) + stderr_utf8_lossy = Str.from_utf8_lossy(stderr_bytes) -# ## Converts output into a utf8 string, ignoring any invalid utf8 sequences. -# to_str_lossy : Output -> Str -# to_str_lossy = |output| -# InternalCmd.output_to_str_lossy(output) + Ok({ stdout_utf8, stderr_utf8_lossy }) -## Create a new command to execute the given program in a child process. -new : Str -> Cmd -new = |program| - @Cmd( - { - program, - args: [], - envs: [], - clear_envs: Bool.false, - }, - ) + Err(inside_res) -> + when inside_res is + Ok({ exit_code, stderr_bytes, stdout_bytes }) -> + stdout_utf8_lossy = Str.from_utf8_lossy(stdout_bytes) + stderr_utf8_lossy = Str.from_utf8_lossy(stderr_bytes) -## Add a single argument to the command. -## ! Shell features like variable subsitition (e.g. `$FOO`), glob patterns (e.g. `*.txt`), ... are not available. + Err(NonZeroExitCode({ command: to_str(cmd), exit_code, stdout_utf8_lossy, stderr_utf8_lossy })) + + Err(err) -> + Err(FailedToGetExitCode({ command: to_str(cmd), err: InternalIOErr.handle_err(err) })) + +## Execute command and capture stdout and stderr in the original form as bytes. +## +## > Stdin is not inherited from the parent and any attempt by the child process +## > to read from the stdin stream will result in the stream immediately closing. +## +## Use [exec_output!] instead if you want to get the output as UTF-8 strings. ## ## ``` -## # Represent the command "ls -l" -## Cmd.new("ls") -## |> Cmd.arg("-l") +## cmd_output_bytes = +## Cmd.new("echo") +## |> Cmd.args(["Hi"]) +## |> Cmd.exec_output_bytes!()? +## +## Stdout.line!("${Inspect.to_str(cmd_output_bytes)}")? # {stderr_bytes: [], stdout_bytes: [72, 105, 10]} ## ``` ## -arg : Cmd, Str -> Cmd -arg = |@Cmd(cmd), value| - @Cmd({ cmd & args: List.append(cmd.args, value) }) +exec_output_bytes! : Cmd => Result { stderr_bytes : List U8, stdout_bytes : List U8 } [FailedToGetExitCodeB InternalIOErr.IOErr, NonZeroExitCodeB { exit_code : I32, stderr_bytes : List U8, stdout_bytes : List U8 }] +exec_output_bytes! = |@Cmd(cmd)| + exec_res = Host.command_exec_output!(cmd) -## Add multiple arguments to the command. -## ! Shell features like variable subsitition (e.g. `$FOO`), glob patterns (e.g. `*.txt`), ... are not available. + when exec_res is + Ok({ stderr_bytes, stdout_bytes }) -> + Ok({ stdout_bytes, stderr_bytes }) + + Err(inside_res) -> + when inside_res is + Ok({ exit_code, stderr_bytes, stdout_bytes }) -> + Err(NonZeroExitCodeB({ exit_code, stdout_bytes, stderr_bytes })) + + Err(err) -> + Err(FailedToGetExitCodeB(InternalIOErr.handle_err(err))) + +## Execute command and inherit stdin, stdout and stderr from parent. Returns the exit code. +## +## You should prefer using [exec!] or [exec_cmd!] instead, only use this if you want to take a specific action based on a **specific non-zero exit code**. +## For example, `roc check` returns exit code 1 if there are errors, and exit code 2 if there are only warnings. +## So, you could use `exec_exit_code!` to ignore warnings on `roc check`. ## ## ``` -## # Represent the command "ls -l -a" -## Cmd.new("ls") -## |> Cmd.args(["-l", "-a"]) +## exit_code = +## Cmd.new("cat") +## |> Cmd.args(["non_existent.txt"]) +## |> Cmd.exec_exit_code!()? +## +## Stdout.line!("${Num.to_str(exit_code)}")? # "1" ## ``` ## -args : Cmd, List Str -> Cmd -args = |@Cmd(cmd), values| - @Cmd({ cmd & args: List.concat(cmd.args, values) }) +exec_exit_code! : Cmd => Result I32 [FailedToGetExitCode { command : Str, err : IOErr }] +exec_exit_code! = |@Cmd(cmd)| + Host.command_exec_exit_code!(cmd) + |> Result.map_err(InternalIOErr.handle_err) + |> Result.map_err(|err| FailedToGetExitCode({ command: to_str(cmd), err })) + +## Represents a command to be executed in a child process. +Cmd := InternalCmd.Command ## Add a single environment variable to the command. ## @@ -107,7 +186,7 @@ envs = |@Cmd(cmd), key_values| @Cmd({ cmd & envs: List.concat(cmd.envs, values) }) ## Clear all environment variables, and prevent inheriting from parent, only -## the environment variables provided to command are available to the child. +## the environment variables provided by [env] or [envs] are available to the child. ## ## ``` ## # Represents "env" with only "FOO" environment variable set @@ -120,38 +199,40 @@ clear_envs : Cmd -> Cmd clear_envs = |@Cmd(cmd)| @Cmd({ cmd & clear_envs: Bool.true }) -## Execute command and capture stdout, stderr and the exit code in [Output]. -## -## > Stdin is not inherited from the parent and any attempt by the child process -## > to read from the stdin stream will result in the stream immediately closing. -## -output! : Cmd => Output -output! = |@Cmd(cmd)| - Host.command_output!(cmd) - |> InternalCmd.from_host_output +## Create a new command to execute the given program in a child process. +new : Str -> Cmd +new = |program| + @Cmd( + { + program, + args: [], + envs: [], + clear_envs: Bool.false, + }, + ) -## Execute command and inherit stdin, stdout and stderr from parent. Returns the exit code. +## Add a single argument to the command. +## ❗ Shell features like variable subsitition (e.g. `$FOO`), glob patterns (e.g. `*.txt`), ... are not available. ## -status! : Cmd => Result I32 [CmdStatusErr InternalIOErr.IOErr] -status! = |@Cmd(cmd)| - Host.command_status!(cmd) - |> Result.map_err(InternalIOErr.handle_err) - |> Result.map_err(CmdStatusErr) +## ``` +## # Represent the command "ls -l" +## Cmd.new("ls") +## |> Cmd.arg("-l") +## ``` +## +arg : Cmd, Str -> Cmd +arg = |@Cmd(cmd), value| + @Cmd({ cmd & args: List.append(cmd.args, value) }) -## Simplest way to execute a command while inheriting stdin, stdout and stderr from parent. +## Add multiple arguments to the command. +## ❗ Shell features like variable subsitition (e.g. `$FOO`), glob patterns (e.g. `*.txt`), ... are not available. ## ## ``` -## # Call echo to print "hello world" -## Cmd.exec!("echo", ["hello world"]) ? |err| CmdEchoFailed(err) +## # Represent the command "ls -l -a" +## Cmd.new("ls") +## |> Cmd.args(["-l", "-a"]) ## ``` -exec! : Str, List Str => Result {} [CmdStatusErr InternalIOErr.IOErr] -exec! = |program, arguments| - exit_code = - new(program) - |> args(arguments) - |> status!? - - if exit_code == 0i32 then - Ok({}) - else - Err(CmdStatusErr(Other("Non-zero exit code ${Num.to_str(exit_code)}"))) +## +args : Cmd, List Str -> Cmd +args = |@Cmd(cmd), values| + @Cmd({ cmd & args: List.concat(cmd.args, values) }) diff --git a/platform/Host.roc b/platform/Host.roc index a64db81b..97c72812 100644 --- a/platform/Host.roc +++ b/platform/Host.roc @@ -1,8 +1,8 @@ hosted [ FileReader, TcpStream, - command_output!, - command_status!, + command_exec_output!, + command_exec_exit_code!, current_arch_os!, cwd!, dir_create!, @@ -67,8 +67,8 @@ import InternalPath import InternalIOErr import InternalSqlite # COMMAND -command_status! : InternalCmd.Command => Result I32 InternalIOErr.IOErrFromHost -command_output! : InternalCmd.Command => InternalCmd.OutputFromHost +command_exec_exit_code! : InternalCmd.Command => Result I32 InternalIOErr.IOErrFromHost +command_exec_output! : InternalCmd.Command => Result InternalCmd.OutputFromHostSuccess (Result InternalCmd.OutputFromHostFailure InternalIOErr.IOErrFromHost) # FILE file_write_bytes! : List U8, List U8 => Result {} InternalIOErr.IOErrFromHost diff --git a/platform/InternalCmd.roc b/platform/InternalCmd.roc index 741c2403..55cc0c9c 100644 --- a/platform/InternalCmd.roc +++ b/platform/InternalCmd.roc @@ -1,62 +1,41 @@ module [ Command, - Output, - OutputFromHost, - from_host_output, + OutputFromHostSuccess, + OutputFromHostFailure, + to_str, ] -import InternalIOErr - Command : { program : Str, args : List Str, # [arg0, arg1, arg2, arg3, ...] - envs : List Str, # [key0, value0, key1, value1, key2, value2, ...] + envs : List Str, # TODO change this to list of tuples? [key0, value0, key1, value1, key2, value2, ...] clear_envs : Bool, } -Output : { - status : Result I32 InternalIOErr.IOErr, - stdout : List U8, - stderr : List U8, +# Do not change the order of the fields! It will lead to a segfault. +OutputFromHostSuccess : { + stderr_bytes : List U8, + stdout_bytes : List U8, } -# This hits a compiler bug: Alias `6.IdentId(11)` not registered in delayed aliases! ... -# output_to_str : Output -> Result Str [BadUtf8 { index : U64, problem : Str.Utf8Problem }] -# output_to_str = |cmd_output| -# stdout_utf8 = Str.from_utf8(cmd_output.stdout)? -# stderr_utf8 = Str.from_utf8_lossy(cmd_output.stderr) - -# Ok( -# output_str_template(cmd_output.status, stdout_utf8, stderr_utf8) -# ) - -# output_to_str_lossy : Output -> Str -# output_to_str_lossy = |cmd_output| -# stdout_utf8 = Str.from_utf8_lossy(cmd_output.stdout) -# stderr_utf8 = Str.from_utf8_lossy(cmd_output.stderr) +# Do not change the order of the fields! It will lead to a segfault. +OutputFromHostFailure : { + stderr_bytes : List U8, + stdout_bytes : List U8, + exit_code : I32, +} - -# output_str_template(cmd_output.status, stdout_utf8, stderr_utf8) +to_str : Command -> Str +to_str = |cmd| + envs_str = + cmd.envs + #|> List.map(|(key, value)| "${key}=${value}") + |> Str.join_with(" ") + |> Str.trim() + |> (|trimmed_str| if Str.is_empty(trimmed_str) then "" else "envs: ${trimmed_str}") -# output_str_template : Result I32 InternalIOErr.IOErr, Str, Str -> Str -# output_str_template = |status, stdout_utf8, stderr_utf8| -# """ -# Output { -# status: ${Inspect.to_str(status)} -# stdout: ${stdout_utf8} -# stderr: ${stderr_utf8} -# } -# """ + clear_envs_str = if cmd.clear_envs then ", clear_envs: true" else "" -from_host_output : OutputFromHost -> Output -from_host_output = |{ status, stdout, stderr }| { - status: Result.map_err(status, InternalIOErr.handle_err), - stdout, - stderr, -} - -OutputFromHost : { - status : Result I32 InternalIOErr.IOErrFromHost, - stdout : List U8, - stderr : List U8, -} + """ + { cmd: ${cmd.program}, args: ${Str.join_with(cmd.args, " ")}${envs_str}${clear_envs_str} } + """ \ No newline at end of file diff --git a/platform/main.roc b/platform/main.roc index e8178648..0d7ed3b1 100644 --- a/platform/main.roc +++ b/platform/main.roc @@ -44,15 +44,24 @@ main_for_host! = |raw_args| _ = Stderr.line!(msg) code - Err(msg) -> + Err(err) -> + err_str = Inspect.to_str(err) + + clean_err_str = + # Inspect adds parentheses around errors, which are unnecessary here. + if Str.starts_with(err_str, "(") and Str.ends_with(err_str, ")") then + err_str + |> Str.replace_first("(", "") + |> Str.replace_last(")", "") + else + err_str + help_msg = """ Program exited with error: - ❌ ${Inspect.to_str(msg)} - - Tip: If you do not want to exit on this error, use `Result.map_err` to handle the error. Docs for `Result.map_err`: + ❌ ${clean_err_str} """ _ = Stderr.line!(help_msg) diff --git a/tests/cmd-test.roc b/tests/cmd-test.roc new file mode 100644 index 00000000..8de2ffd1 --- /dev/null +++ b/tests/cmd-test.roc @@ -0,0 +1,119 @@ +app [main!] { pf: platform "../platform/main.roc" } + +import pf.Stdout +import pf.Cmd +import pf.Arg exposing [Arg] + +# Tests all error cases in Cmd functions. + +main! : List Arg => Result {} _ +main! = |_args| + + # exec! + expect_err( + Cmd.exec!("blablaXYZ", []), + "(Err (FailedToGetExitCode {command: \"{ cmd: blablaXYZ, args: }\", err: NotFound}))" + )? + + expect_err( + Cmd.exec!("cat", ["non_existent.txt"]), + "(Err (ExecFailed {command: \"cat non_existent.txt\", exit_code: 1}))" + )? + + # exec_cmd! + expect_err( + Cmd.new("blablaXYZ") + |> Cmd.exec_cmd!, + "(Err (FailedToGetExitCode {command: \"{ cmd: blablaXYZ, args: }\", err: NotFound}))" + )? + + expect_err( + Cmd.new("cat") + |> Cmd.arg("non_existent.txt") + |> Cmd.exec_cmd!, + "(Err (ExecCmdFailed {command: \"{ cmd: cat, args: non_existent.txt }\", exit_code: 1}))" + )? + + # exec_output! + expect_err( + Cmd.new("blablaXYZ") + |> Cmd.exec_output!, + "(Err (FailedToGetExitCode {command: \"{ cmd: blablaXYZ, args: }\", err: NotFound}))" + )? + + expect_err( + Cmd.new("cat") + |> Cmd.arg("non_existent.txt") + |> Cmd.exec_output!, + "(Err (NonZeroExitCode {command: \"{ cmd: cat, args: non_existent.txt }\", exit_code: 1, stderr_utf8_lossy: \"cat: non_existent.txt: No such file or directory\n\", stdout_utf8_lossy: \"\"}))" + )? + + # Test StdoutContainsInvalidUtf8 - using printf to output invalid UTF-8 bytes + expect_err( + Cmd.new("printf") + |> Cmd.arg("\\xff\\xfe") # Invalid UTF-8 sequence + |> Cmd.exec_output!, + "(Err (StdoutContainsInvalidUtf8 {cmd_str: \"{ cmd: printf, args: \\xff\\xfe }\", err: (BadUtf8 {index: 0, problem: InvalidStartByte})}))" + )? + + # exec_output_bytes! + expect_err( + Cmd.new("blablaXYZ") + |> Cmd.exec_output_bytes!, + "(Err (FailedToGetExitCodeB NotFound))" + )? + + expect_err( + Cmd.new("cat") + |> Cmd.arg("non_existent.txt") + |> Cmd.exec_output_bytes!, + "(Err (NonZeroExitCodeB {exit_code: 1, stderr_bytes: [99, 97, 116, 58, 32, 110, 111, 110, 95, 101, 120, 105, 115, 116, 101, 110, 116, 46, 116, 120, 116, 58, 32, 78, 111, 32, 115, 117, 99, 104, 32, 102, 105, 108, 101, 32, 111, 114, 32, 100, 105, 114, 101, 99, 116, 111, 114, 121, 10], stdout_bytes: []}))" + )? + + # exec_exit_code! + expect_err( + Cmd.new("blablaXYZ") + |> Cmd.exec_exit_code!, + "(Err (FailedToGetExitCode {command: \"{ cmd: blablaXYZ, args: }\", err: NotFound}))" + )? + + # exec_exit_code! with non-zero exit code is not an error - it returns the exit code + exit_code = + Cmd.new("cat") + |> Cmd.arg("non_existent.txt") + |> Cmd.exec_exit_code!()? + + if exit_code == 1 then + Ok({})? + else + Err(FailedExpectation( + """ + + - Expected: + 1 + + - Got: + ${Inspect.to_str(exit_code)} + + """ + ))? + + Stdout.line!("All tests passed.")? + + Ok({}) + +expect_err = |err, expected_str| + if Inspect.to_str(err) == expected_str then + Ok({}) + else + Err(FailedExpectation( + """ + + - Expected: + ${expected_str} + + - Got: + ${Inspect.to_str(err)} + + """ + )) \ No newline at end of file diff --git a/tests/file.roc b/tests/file.roc index d72216f8..953cabd2 100644 --- a/tests/file.roc +++ b/tests/file.roc @@ -144,12 +144,10 @@ test_hard_link! = |{}| ls_li_output = Cmd.new("ls") |> Cmd.args(["-li", "test_original_file.txt", "test_link_to_original.txt"]) - |> Cmd.output!() - - ls_li_stdout_utf8 = Str.from_utf8(ls_li_output.stdout) ? |_| LsLiInvalidUtf8 + |> Cmd.exec_output!()? inodes = - Str.split_on(ls_li_stdout_utf8, "\n") + Str.split_on(ls_li_output.stdout_utf8, "\n") |> List.map(|line| Str.split_on(line, " ") |> List.take_first(1) diff --git a/tests/path-test.roc b/tests/path-test.roc index 28f08e06..cc5d893d 100644 --- a/tests/path-test.roc +++ b/tests/path-test.roc @@ -93,15 +93,13 @@ test_file_operations! = |{}| bytes_path = Path.from_str("test_path_bytes.txt") Path.write_bytes!(test_bytes, bytes_path)? - # Verify file exists using ls - ls_output = Cmd.new("ls") |> Cmd.args(["-la", "test_path_bytes.txt"]) |> Cmd.output!() - ls_exit_code = ls_output.status ? |err| LsFailedToGetExitCode(err) + # Verify file exists + _ = Cmd.exec!("test", ["-e", "test_path_bytes.txt"])? read_bytes = Path.read_bytes!(bytes_path)? Stdout.line!( """ - test_path_bytes.txt exists: ${Inspect.to_str(ls_exit_code == 0)} Bytes written: ${Inspect.to_str(test_bytes)} Bytes read: ${Inspect.to_str(read_bytes)} Bytes match: ${Inspect.to_str(test_bytes == read_bytes)} @@ -114,14 +112,13 @@ test_file_operations! = |{}| Path.write_utf8!(utf8_content, utf8_path)? # Check file content with cat - cat_output = Cmd.new("cat") |> Cmd.args(["test_path_utf8.txt"]) |> Cmd.output!() - cat_stdout = Str.from_utf8(cat_output.stdout) ? |_| CatInvalidUtf8 + cat_output = Cmd.new("cat") |> Cmd.args(["test_path_utf8.txt"]) |> Cmd.exec_output!()? read_utf8 = Path.read_utf8!(utf8_path)? Stdout.line!( """ - File content via cat: ${cat_stdout} + File content via cat: ${cat_output.stdout_utf8} UTF-8 written: ${utf8_content} UTF-8 read: ${read_utf8} UTF-8 content matches: ${Inspect.to_str(utf8_content == read_utf8)} @@ -152,17 +149,16 @@ test_file_operations! = |{}| Path.write_utf8!("This file will be deleted", delete_path)? # Verify file exists before deletion - ls_before = Cmd.new("ls") |> Cmd.args(["test_to_delete.txt"]) |> Cmd.output!() + _ = Cmd.exec!("test", ["-e", "test_to_delete.txt"])? Path.delete!(delete_path) ? |err| DeleteFailed(err) # Verify file is gone after deletion - ls_after = Cmd.new("ls") |> Cmd.args(["test_to_delete.txt"]) |> Cmd.output!() + exists_after_res = Cmd.exec!("test", ["-e", "test_to_delete.txt"]) Stdout.line!( """ - File exists before delete: ${Inspect.to_str(ls_before.status? == 0)} - File exists after delete: ${Inspect.to_str(ls_after.status? == 0)} + File no longer exists: ${Inspect.to_str(Result.is_err(exists_after_res))} """ )? @@ -170,39 +166,29 @@ test_file_operations! = |{}| test_directory_operations! : {} => Result {} _ test_directory_operations! = |{}| - Stdout.line!("\nTesting Path directory operations:")? + Stdout.line!("\nTesting Path directory operations...")? # Test Path.create_dir! single_dir = Path.from_str("test_single_dir") Path.create_dir!(single_dir)? # Verify directory exists - ls_dir = Cmd.new("ls") |> Cmd.args(["-ld", "test_single_dir"]) |> Cmd.output!() - ls_dir_stdout = Str.from_utf8(ls_dir.stdout) ? |_| LsDirInvalidUtf8 - is_dir = Str.starts_with(ls_dir_stdout, "d") - - Stdout.line!( - """ - Created directory: ${Str.trim_end(ls_dir_stdout)} - Is a directory: ${Inspect.to_str(is_dir)}\n - """ - )? + _ = Cmd.exec!("test", ["-d", "test_single_dir"])? # Test Path.create_all! (nested directories) nested_dir = Path.from_str("test_parent/test_child/test_grandchild") Path.create_all!(nested_dir)? # Verify nested structure with find - find_output = Cmd.new("find") |> Cmd.args(["test_parent", "-type", "d"]) |> Cmd.output!() - find_stdout = Str.from_utf8(find_output.stdout) ? |_| FindInvalidUtf8 + find_output = Cmd.new("find") |> Cmd.args(["test_parent", "-type", "d"]) |> Cmd.exec_output!()? # Count directories created - dir_count = Str.split_on(find_stdout, "\n") |> List.len + dir_count = Str.split_on(find_output.stdout_utf8, "\n") |> List.len Stdout.line!( """ Nested directory structure: - ${find_stdout} + ${find_output.stdout_utf8} Number of directories created: ${Num.to_str(dir_count - 1)} """ )? @@ -213,13 +199,12 @@ test_directory_operations! = |{}| Path.create_dir!(Path.from_str("test_single_dir/subdir"))? # List directory contents - ls_contents = Cmd.new("ls") |> Cmd.args(["-la", "test_single_dir"]) |> Cmd.output!() - ls_contents_stdout = Str.from_utf8(ls_contents.stdout) ? |_| LsContentsInvalidUtf8 + ls_contents = Cmd.new("ls") |> Cmd.args(["-la", "test_single_dir"]) |> Cmd.exec_output!()? Stdout.line!( """ Directory contents: - ${ls_contents_stdout} + ${ls_contents.stdout_utf8} """ )? @@ -228,34 +213,32 @@ test_directory_operations! = |{}| Path.create_dir!(empty_dir)? # Verify it exists - ls_empty_before = Cmd.new("ls") |> Cmd.args(["-ld", "test_empty_dir"]) |> Cmd.output!() + _ = Cmd.exec!("test", ["-e", "test_empty_dir"])? Path.delete_empty!(empty_dir)? # Verify it's gone - ls_empty_after = Cmd.new("ls") |> Cmd.args(["-ld", "test_empty_dir"]) |> Cmd.output!() + exists_after_res = Cmd.exec!("test", ["-e", "test_empty_dir"]) Stdout.line!( """ - Empty dir exists before delete: ${Inspect.to_str(ls_empty_before.status? == 0)} - Empty dir exists after delete: ${Inspect.to_str(ls_empty_after.status? == 0)} + Empty dir was deleted: ${Inspect.to_str(Result.is_err(exists_after_res))} """ )? # Test Path.delete_all! # First show what we're about to delete - du_output = Cmd.new("du") |> Cmd.args(["-sh", "test_parent"]) |> Cmd.output!() - du_stdout = Str.from_utf8(du_output.stdout) ? |_| DuInvalidUtf8 + du_output = Cmd.new("du") |> Cmd.args(["-sh", "test_parent"]) |> Cmd.exec_output!()? Path.delete_all!(Path.from_str("test_parent"))? # Verify it's gone - ls_parent_after = Cmd.new("ls") |> Cmd.args(["test_parent"]) |> Cmd.output!() + parent_exists_afer_res = Cmd.exec!("test", ["-e", "test_parent"]) Stdout.line!( """ - Size before delete_all: ${du_stdout} - Parent dir exists after delete_all: ${Inspect.to_str(ls_parent_after.status? == 0)} + Size before delete_all: ${du_output.stdout_utf8} + Parent dir no longer exists: ${Inspect.to_str(Result.is_err(parent_exists_afer_res))} """ )? @@ -273,16 +256,14 @@ test_hard_link! = |{}| Path.write_utf8!("Original content for Path hard link test", original_path)? # Get original file stats - stat_before = Cmd.new("stat") |> Cmd.args(["-c", "%h", "test_path_original.txt"]) |> Cmd.output!() - links_before = Str.from_utf8(stat_before.stdout) ? |_| StatBeforeInvalidUtf8 + stat_before = Cmd.new("stat") |> Cmd.args(["-c", "%h", "test_path_original.txt"]) |> Cmd.exec_output!()? # Create hard link link_path = Path.from_str("test_path_hardlink.txt") when Path.hard_link!(original_path, link_path) is Ok({}) -> # Get link count after - stat_after = Cmd.new("stat") |> Cmd.args(["-c", "%h", "test_path_original.txt"]) |> Cmd.output!() - links_after = Str.from_utf8(stat_after.stdout) ? |_| StatAfterInvalidUtf8 + stat_after = Cmd.new("stat") |> Cmd.args(["-c", "%h", "test_path_original.txt"]) |> Cmd.exec_output!()? # Verify both files exist and have same content original_content = Path.read_utf8!(original_path)? @@ -290,8 +271,8 @@ test_hard_link! = |{}| Stdout.line!( """ - Hard link count before: ${Str.trim(links_before)} - Hard link count after: ${Str.trim(links_after)} + Hard link count before: ${Str.trim(stat_before.stdout_utf8)} + Hard link count after: ${Str.trim(stat_after.stdout_utf8)} Original content: ${original_content} Link content: ${link_content} Content matches: ${Inspect.to_str(original_content == link_content)} @@ -302,12 +283,10 @@ test_hard_link! = |{}| ls_li_output = Cmd.new("ls") |> Cmd.args(["-li", "test_path_original.txt", "test_path_hardlink.txt"]) - |> Cmd.output!() - - ls_li_stdout_utf8 = Str.from_utf8(ls_li_output.stdout) ? |_| LsLiInvalidUtf8 + |> Cmd.exec_output!()? inodes = - Str.split_on(ls_li_stdout_utf8, "\n") + Str.split_on(ls_li_output.stdout_utf8, "\n") |> List.map(|line| Str.split_on(line, " ") |> List.take_first(1) @@ -319,7 +298,7 @@ test_hard_link! = |{}| Stdout.line!( """ Inode information: - ${ls_li_stdout_utf8} + ${ls_li_output.stdout_utf8} First file inode: ${Inspect.to_str(first_inode)} Second file inode: ${Inspect.to_str(second_inode)} Inodes are equal: ${Inspect.to_str(first_inode == second_inode)} @@ -411,35 +390,30 @@ cleanup_test_files! = |files_requirement| ] # Show files before cleanup - ls_before_cleanup = Cmd.new("ls") |> Cmd.args(["-la"] |> List.concat(test_files)) |> Cmd.output!() + ls_before_cleanup = Cmd.new("ls") |> Cmd.args(["-la"] |> List.concat(test_files)) |> Cmd.exec_output!()? - if ls_before_cleanup.status? == 0 then - cleanup_stdout = Str.from_utf8(ls_before_cleanup.stdout) ? |_| CleanupInvalidUtf8 - - Stdout.line!( - """ - Files to clean up: - ${cleanup_stdout} - """ - )? - - delete_result = List.for_each_try!(test_files, |filename| - Path.delete!(Path.from_str(filename)) - ) - - when files_requirement is - FilesNeedToExist -> - delete_result ? |err| FileDeletionFailed(err) - FilesMaybeExist -> - Ok({})? - - # Verify cleanup - ls_after_cleanup = Cmd.new("ls") |> Cmd.args(test_files) |> Cmd.output!() - - Stdout.line!( - """ - Files remaining after cleanup: ${Inspect.to_str(ls_after_cleanup.status? == 0)} - """ - ) - else - Stderr.line!("✗ Error listing files before cleanup: `ls -la ...` exited with non-zero exit code:\n\t${Inspect.to_str(ls_before_cleanup)}") \ No newline at end of file + Stdout.line!( + """ + Files to clean up: + ${ls_before_cleanup.stdout_utf8} + """ + )? + + delete_result = List.for_each_try!(test_files, |filename| + Path.delete!(Path.from_str(filename)) + ) + + when files_requirement is + FilesNeedToExist -> + delete_result ? |err| FileDeletionFailed(err) + FilesMaybeExist -> + Ok({})? + + # Verify cleanup + ls_after_cleanup_res = Cmd.exec!("ls", test_files) + + Stdout.line!( + """ + Files deleted successfully: ${Inspect.to_str(Result.is_err(ls_after_cleanup_res))} + """ + )