diff --git a/Cargo.lock b/Cargo.lock index 7a061ec..b8ab949 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -99,6 +99,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "ascii_tree" version = "0.1.1" @@ -197,7 +203,9 @@ checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", + "wasm-bindgen", "windows-targets 0.52.6", ] @@ -353,6 +361,18 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "dtparse" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23fb403c0926d35af2cc54d961bc2696a10d40725c08360ef69db04a4c201fd7" +dependencies = [ + "chrono", + "lazy_static", + "num-traits", + "rust_decimal", +] + [[package]] name = "dunce" version = "1.0.5" @@ -410,6 +430,18 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "filetime" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.59.0", +] + [[package]] name = "futures" version = "0.3.30" @@ -643,6 +675,7 @@ checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ "bitflags", "libc", + "redox_syscall", ] [[package]] @@ -1074,6 +1107,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +[[package]] +name = "rust_decimal" +version = "1.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b082d80e3e3cc52b2ed634388d436fe1f4de6af5786cc2de9ba9737527bdf555" +dependencies = [ + "arrayvec", + "num-traits", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -1186,18 +1229,24 @@ dependencies = [ name = "shell" version = "0.1.0" dependencies = [ + "chrono", "clap", "ctrlc", "deno_task_shell", "dirs", + "dtparse", + "filetime", "futures", "miette", + "parse_datetime", "rustyline", "tokio", "uu_date", "uu_ls", + "uu_touch", "uu_uname", "which", + "windows-sys 0.59.0", ] [[package]] @@ -1312,6 +1361,7 @@ name = "tests" version = "0.1.0" dependencies = [ "deno_task_shell", + "dirs", "futures", "miette", "pretty_assertions", @@ -1333,18 +1383,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "1.0.63" +version = "1.0.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +checksum = "d50af8abc119fb8bb6dbabcfa89656f46f84aa0ac7688088608076ad2b459a84" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.63" +version = "1.0.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" dependencies = [ "proc-macro2", "quote", @@ -1468,6 +1518,20 @@ dependencies = [ "uutils_term_grid", ] +[[package]] +name = "uu_touch" +version = "0.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07698cd67da84b138369eeed24bee77c860b8b22021581a56edd31c4697dd61d" +dependencies = [ + "chrono", + "clap", + "filetime", + "parse_datetime", + "uucore", + "windows-sys 0.48.0", +] + [[package]] name = "uu_uname" version = "0.0.27" diff --git a/crates/deno_task_shell/Cargo.toml b/crates/deno_task_shell/Cargo.toml index 20916ac..778b834 100644 --- a/crates/deno_task_shell/Cargo.toml +++ b/crates/deno_task_shell/Cargo.toml @@ -27,7 +27,7 @@ pest = { git = "https://github.com/pest-parser/pest.git", branch = "master", fea pest_derive = "2.7.12" dirs = "5.0.1" pest_ascii_tree = { git = "https://github.com/prsabahrami/pest_ascii_tree.git", branch = "master" } -miette = "7.2.0" +miette = { version = "7.2.0", features = ["fancy"] } lazy_static = "1.4.0" [dev-dependencies] diff --git a/crates/shell/Cargo.toml b/crates/shell/Cargo.toml index badd336..ee5449c 100644 --- a/crates/shell/Cargo.toml +++ b/crates/shell/Cargo.toml @@ -32,9 +32,15 @@ uu_ls = "0.0.27" dirs = "5.0.1" which = "6.0.3" uu_uname = "0.0.27" +uu_touch = "0.0.27" uu_date = "0.0.27" +miette = { version = "7.2.0", features = ["fancy"] } +filetime = "0.2.25" +chrono = "0.4.38" +parse_datetime = "0.6.0" +dtparse = "2.0.1" +windows-sys = "0.59.0" ctrlc = "3.4.5" -miette = { version="7.2.0", features = ["fancy"] } [package.metadata.release] # Dont publish the binary diff --git a/crates/shell/src/commands/mod.rs b/crates/shell/src/commands/mod.rs index f8dff0a..86f16db 100644 --- a/crates/shell/src/commands/mod.rs +++ b/crates/shell/src/commands/mod.rs @@ -8,10 +8,12 @@ use uu_ls::uumain as uu_ls; use crate::execute; pub mod date; +pub mod touch; pub mod uname; pub mod which; pub use date::DateCommand; +pub use touch::TouchCommand; pub use uname::UnameCommand; pub use which::WhichCommand; @@ -46,6 +48,10 @@ pub fn get_commands() -> HashMap> { "uname".to_string(), Rc::new(UnameCommand) as Rc, ), + ( + "touch".to_string(), + Rc::new(TouchCommand) as Rc, + ), ( "date".to_string(), Rc::new(DateCommand) as Rc, diff --git a/crates/shell/src/commands/touch.rs b/crates/shell/src/commands/touch.rs new file mode 100644 index 0000000..87c9481 --- /dev/null +++ b/crates/shell/src/commands/touch.rs @@ -0,0 +1,346 @@ +use std::{ + ffi::OsString, + fs::{self, OpenOptions}, + io, + path::{Path, PathBuf}, +}; + +use chrono::{DateTime, Duration, Local, NaiveDateTime, TimeZone, Timelike}; +use deno_task_shell::{ExecuteResult, ShellCommand, ShellCommandContext}; +use filetime::{set_file_times, set_symlink_file_times, FileTime}; +use futures::future::LocalBoxFuture; +use miette::{miette, IntoDiagnostic, Result}; +use uu_touch::{options, uu_app as uu_touch}; + +static ARG_FILES: &str = "files"; + +pub struct TouchCommand; + +impl ShellCommand for TouchCommand { + fn execute(&self, mut context: ShellCommandContext) -> LocalBoxFuture<'static, ExecuteResult> { + Box::pin(futures::future::ready(match execute_touch(&mut context) { + Ok(_) => ExecuteResult::from_exit_code(0), + Err(e) => { + let _ = context.stderr.write_all(format!("{:?}", e).as_bytes()); + ExecuteResult::from_exit_code(1) + } + })) + } +} + +fn execute_touch(context: &mut ShellCommandContext) -> Result<()> { + let matches = uu_touch() + .override_usage("touch [OPTION]...") + .no_binary_name(true) + .try_get_matches_from(&context.args) + .into_diagnostic()?; + + let files = match matches.get_many::(ARG_FILES) { + Some(files) => files.map(|file| { + let path = PathBuf::from(file); + if path.is_absolute() { + path + } else { + context.state.cwd().join(path) + } + }), + None => { + return Err(miette!( + "missing file operand\nTry 'touch --help' for more information." + )) + } + }; + + let (mut atime, mut mtime) = match ( + matches.get_one::(options::sources::REFERENCE), + matches.get_one::(options::sources::DATE), + ) { + (Some(reference), Some(date)) => { + let reference_path = PathBuf::from(reference); + let reference_path = if reference_path.is_absolute() { + reference_path + } else { + context.state.cwd().join(reference_path) + }; + let (atime, mtime) = stat(&reference_path, !matches.get_flag(options::NO_DEREF))?; + let atime = filetime_to_datetime(&atime) + .ok_or_else(|| miette!("Could not process the reference access time"))?; + let mtime = filetime_to_datetime(&mtime) + .ok_or_else(|| miette!("Could not process the reference modification time"))?; + Ok((parse_date(atime, date)?, parse_date(mtime, date)?)) + } + (Some(reference), None) => { + let reference_path = PathBuf::from(reference); + let reference_path = if reference_path.is_absolute() { + reference_path + } else { + context.state.cwd().join(reference_path) + }; + stat(&reference_path, !matches.get_flag(options::NO_DEREF)) + } + (None, Some(date)) => { + let timestamp = parse_date(Local::now(), date)?; + Ok((timestamp, timestamp)) + } + (None, None) => { + let timestamp = if let Some(ts) = matches.get_one::(options::sources::TIMESTAMP) + { + parse_timestamp(ts)? + } else { + datetime_to_filetime(&Local::now()) + }; + Ok((timestamp, timestamp)) + } + } + .map_err(|e| miette!("{}", e))?; + + for filename in files { + let pathbuf = if filename.to_str() == Some("-") { + pathbuf_from_stdout()? + } else { + filename + }; + + let path = pathbuf.as_path(); + + let metadata_result = if matches.get_flag(options::NO_DEREF) { + path.symlink_metadata() + } else { + path.metadata() + }; + + if let Err(e) = metadata_result { + if e.kind() != std::io::ErrorKind::NotFound { + return Err(miette!("setting times of {}: {}", path.display(), e)); + } + + if matches.get_flag(options::NO_CREATE) { + continue; + } + + if matches.get_flag(options::NO_DEREF) { + let _ = context.stderr.write_all( + format!( + "setting times of {:?}: No such file or directory", + path.display() + ) + .as_bytes(), + ); + continue; + } + + OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(path) + .map_err(|e| match e.kind() { + io::ErrorKind::NotFound => { + miette!( + "cannot touch {}: {}", + path.display(), + "No such file or directory".to_string() + ) + } + _ => miette!("cannot touch {}: {}", path.display(), e), + })?; + + // Minor optimization: if no reference time was specified, we're done. + if !matches.contains_id(options::SOURCES) { + continue; + } + } + + if matches.get_flag(options::ACCESS) + || matches.get_flag(options::MODIFICATION) + || matches.contains_id(options::TIME) + { + let st = stat(path, !matches.get_flag(options::NO_DEREF))?; + let time = matches + .get_one::(options::TIME) + .map(|s| s.as_str()) + .unwrap_or(""); + + if !(matches.get_flag(options::ACCESS) + || time.contains(&"access".to_owned()) + || time.contains(&"atime".to_owned()) + || time.contains(&"use".to_owned())) + { + atime = st.0; + } + + if !(matches.get_flag(options::MODIFICATION) + || time.contains(&"modify".to_owned()) + || time.contains(&"mtime".to_owned())) + { + mtime = st.1; + } + } + + // sets the file access and modification times for a file or a symbolic link. + // The filename, access time (atime), and modification time (mtime) are provided as inputs. + + // If the filename is not "-", indicating a special case for touch -h -, + // the code checks if the NO_DEREF flag is set, which means the user wants to + // set the times for a symbolic link itself, rather than the file it points to. + if path.to_string_lossy() == "-" { + set_file_times(path, atime, mtime) + } else if matches.get_flag(options::NO_DEREF) { + set_symlink_file_times(path, atime, mtime) + } else { + set_file_times(path, atime, mtime) + } + .map_err(|e| miette!("setting times of {}: {}", path.display(), e))?; + } + + Ok(()) +} + +fn stat(path: &Path, follow: bool) -> Result<(FileTime, FileTime)> { + let metadata = if follow { + fs::metadata(path).or_else(|_| fs::symlink_metadata(path)) + } else { + fs::symlink_metadata(path) + } + .map_err(|e| miette!("failed to get attributes of {}: {}", path.display(), e))?; + + Ok(( + FileTime::from_last_access_time(&metadata), + FileTime::from_last_modification_time(&metadata), + )) +} + +fn filetime_to_datetime(ft: &FileTime) -> Option> { + Some(DateTime::from_timestamp(ft.unix_seconds(), ft.nanoseconds())?.into()) +} + +fn parse_timestamp(s: &str) -> Result { + let now = Local::now(); + let parsed = if s.len() == 15 && s.contains('.') { + // Handle the specific format "202401010000.00" + NaiveDateTime::parse_from_str(s, "%Y%m%d%H%M.%S") + .map_err(|_| miette!("invalid date format '{}'", s))? + } else { + dtparse::parse(s) + .map(|(dt, _)| dt) + .map_err(|_| miette!("invalid date format '{}'", s))? + }; + + let local = now + .timezone() + .from_local_datetime(&parsed) + .single() + .ok_or_else(|| miette!("invalid date '{}'", s))?; + + // Handle leap seconds + let local = if parsed.second() == 59 && s.ends_with(".60") { + local + Duration::seconds(1) + } else { + local + }; + + // Check for daylight saving time issues + if (local + Duration::hours(1) - Duration::hours(1)).hour() != local.hour() { + return Err(miette!("invalid date '{}'", s)); + } + + Ok(datetime_to_filetime(&local)) +} + +// TODO: this may be a good candidate to put in fsext.rs +/// Returns a PathBuf to stdout. +/// +/// On Windows, uses GetFinalPathNameByHandleW to attempt to get the path +/// from the stdout handle. +fn pathbuf_from_stdout() -> Result { + #[cfg(all(unix, not(target_os = "android")))] + { + Ok(PathBuf::from("/dev/stdout")) + } + #[cfg(target_os = "android")] + { + Ok(PathBuf::from("/proc/self/fd/1")) + } + #[cfg(windows)] + { + use std::os::windows::prelude::AsRawHandle; + use windows_sys::Win32::Foundation::{ + GetLastError, ERROR_INVALID_PARAMETER, ERROR_NOT_ENOUGH_MEMORY, ERROR_PATH_NOT_FOUND, + HANDLE, MAX_PATH, + }; + use windows_sys::Win32::Storage::FileSystem::{ + GetFinalPathNameByHandleW, FILE_NAME_OPENED, + }; + + let handle = std::io::stdout().lock().as_raw_handle() as HANDLE; + let mut file_path_buffer: [u16; MAX_PATH as usize] = [0; MAX_PATH as usize]; + + // https://docs.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfinalpathnamebyhandlea#examples + // SAFETY: We transmute the handle to be able to cast *mut c_void into a + // HANDLE (i32) so rustc will let us call GetFinalPathNameByHandleW. The + // reference example code for GetFinalPathNameByHandleW implies that + // it is safe for us to leave lpszfilepath uninitialized, so long as + // the buffer size is correct. We know the buffer size (MAX_PATH) at + // compile time. MAX_PATH is a small number (260) so we can cast it + // to a u32. + let ret = unsafe { + GetFinalPathNameByHandleW( + handle, + file_path_buffer.as_mut_ptr(), + file_path_buffer.len() as u32, + FILE_NAME_OPENED, + ) + }; + + let buffer_size = match ret { + ERROR_PATH_NOT_FOUND | ERROR_NOT_ENOUGH_MEMORY | ERROR_INVALID_PARAMETER => { + return Err(miette!("GetFinalPathNameByHandleW failed with code {ret}")) + } + 0 => { + return Err(miette!( + "GetFinalPathNameByHandleW failed with code {}", + // SAFETY: GetLastError is thread-safe and has no documented memory unsafety. + unsafe { GetLastError() } + )); + } + e => e as usize, + }; + + // Don't include the null terminator + Ok(String::from_utf16(&file_path_buffer[0..buffer_size]) + .map_err(|e| miette!("Generated path is not valid UTF-16: {e}"))? + .into()) + } +} + +fn parse_date(ref_time: DateTime, s: &str) -> Result { + // Using the dtparse crate for more robust date parsing + + match dtparse::parse(s) { + Ok((naive_dt, offset)) => { + let dt = offset.map_or_else( + || Local.from_local_datetime(&naive_dt).unwrap(), + |off| DateTime::::from_naive_utc_and_offset(naive_dt, off), + ); + Ok(datetime_to_filetime(&dt)) + } + Err(_) => { + // Fallback to parsing Unix timestamp if dtparse fails + if let Some(stripped) = s.strip_prefix('@') { + stripped + .parse::() + .map(|ts| FileTime::from_unix_time(ts, 0)) + .map_err(|_| miette!("Unable to parse date: {s}")) + } else { + // Use ref_time as a base for relative date parsing + parse_datetime::parse_datetime_at_date(ref_time, s) + .map(|dt| datetime_to_filetime(&dt)) + .map_err(|_| miette!("Unable to parse date: {s}")) + } + } + } +} + +fn datetime_to_filetime(dt: &DateTime) -> FileTime { + FileTime::from_unix_time(dt.timestamp(), dt.timestamp_subsec_nanos()) +} diff --git a/crates/tests/Cargo.toml b/crates/tests/Cargo.toml index 87d39ba..3e67bae 100644 --- a/crates/tests/Cargo.toml +++ b/crates/tests/Cargo.toml @@ -8,6 +8,7 @@ deno_task_shell = { path = "../deno_task_shell", features = ["shell"] } shell = { path = "../shell" } futures = "0.3.30" tokio = { version = "1.40.0", features = ["full"] } +dirs = "5.0.1" miette = "7.2.0" [dev-dependencies] diff --git a/crates/tests/src/lib.rs b/crates/tests/src/lib.rs index 5646006..65c5ea7 100644 --- a/crates/tests/src/lib.rs +++ b/crates/tests/src/lib.rs @@ -227,9 +227,16 @@ async fn pipeline() { .run() .await; + // TODO: implement tee in shell and then enable this test + // TestBuilder::new() + // .command(r#"echo 1 | tee output.txt"#) + // .assert_stdout("1\n") + // .assert_file_equals("output.txt", "1\n") + // .run() + // .await; + TestBuilder::new() - .command(r#"echo 1 | tee output.txt"#) - .assert_stdout("1\n") + .command(r#"echo 1 | cat > output.txt"#) .assert_file_equals("output.txt", "1\n") .run() .await; @@ -776,7 +783,6 @@ async fn uname() { TestBuilder::new() .command("uname") .assert_exit_code(0) - .check_stderr(false) .check_stdout(false) .run() .await; @@ -917,6 +923,135 @@ async fn if_clause() { .await; } +#[tokio::test] +async fn touch() { + TestBuilder::new() + .command("touch file.txt") + .assert_exists("file.txt") + .check_stdout(false) + .run() + .await; + + TestBuilder::new() + .command("touch -m file.txt") + .assert_exists("file.txt") + .run() + .await; + + TestBuilder::new() + .command("touch -c nonexistent.txt") + .assert_not_exists("nonexistent.txt") + .run() + .await; + + TestBuilder::new() + .command("touch file1.txt file2.txt") + .assert_exists("file1.txt") + .assert_exists("file2.txt") + .run() + .await; + + TestBuilder::new() + .command("touch -d 'Tue Feb 20 14:30:00 2024' posix_locale.txt") + .assert_exists("posix_locale.txt") + .run() + .await; + + TestBuilder::new() + .command("touch -d '2024-02-20' iso_8601.txt") + .assert_exists("iso_8601.txt") + .run() + .await; + + TestBuilder::new() + .command("touch -t 202402201430.00 yyyymmddhhmmss.txt") + .assert_exists("yyyymmddhhmmss.txt") + .run() + .await; + + TestBuilder::new() + .command("touch -d '2024-02-20 14:30:00.000000' yyyymmddhhmmss_ms.txt") + .assert_exists("yyyymmddhhmmss_ms.txt") + .run() + .await; + + TestBuilder::new() + .command("touch -d '2024-02-20 14:30' yyyy_mm_dd_hh_mm.txt") + .assert_exists("yyyy_mm_dd_hh_mm.txt") + .run() + .await; + + TestBuilder::new() + .command("touch -t 202402201430 yyyymmddhhmm.txt") + .assert_exists("yyyymmddhhmm.txt") + .run() + .await; + + TestBuilder::new() + .command("touch -d '2024-02-20 14:30 +0000' yyyymmddhhmm_offset.txt") + .assert_exists("yyyymmddhhmm_offset.txt") + .run() + .await; + + TestBuilder::new() + .command("touch file.txt && touch -r file.txt reference.txt") + .assert_exists("reference.txt") + .run() + .await; + // Test for non-existent file with -c option + TestBuilder::new() + .command("touch -c nonexistent.txt") + .assert_not_exists("nonexistent.txt") + .run() + .await; + + // Test for invalid date format + TestBuilder::new() + .command("touch -d 'invalid date' invalid_date.txt") + .assert_stderr_contains("Unable to parse date: invalid date\n") + .assert_exit_code(1) + .run() + .await; + + // Test for invalid timestamp format + TestBuilder::new() + .command("touch -t 9999999999 invalid_timestamp.txt") + .assert_stderr_contains("invalid date format '9999999999'\n") + .assert_exit_code(1) + .run() + .await; + + TestBuilder::new() + .command("touch $TEMP_DIR/absolute_path.txt") + .assert_exists("$TEMP_DIR/absolute_path.txt") + .run() + .await; + + TestBuilder::new() + .command("touch $TEMP_DIR/non_existent_dir/non_existent.txt") + .assert_stderr_contains("No such file or directory") + .assert_exit_code(1) + .run() + .await; + + // TODO: implement ln in shell and then enable this test + // // Test with -h option on a symlink + // TestBuilder::new() + // .command("touch original.txt && ln -s original.txt symlink.txt && touch -h symlink.txt") + // .assert_exists("symlink.txt") + // .run() + // .await; + + // Test with multiple files, including one that doesn't exist + TestBuilder::new() + .command("touch existing.txt && touch existing.txt nonexistent.txt another_existing.txt") + .assert_exists("existing.txt") + .assert_exists("nonexistent.txt") + .assert_exists("another_existing.txt") + .run() + .await; +} + #[cfg(test)] fn no_such_file_error_text() -> &'static str { if cfg!(windows) { diff --git a/crates/tests/src/test_builder.rs b/crates/tests/src/test_builder.rs index 77b9afe..e45720f 100644 --- a/crates/tests/src/test_builder.rs +++ b/crates/tests/src/test_builder.rs @@ -65,6 +65,7 @@ pub struct TestBuilder { expected_exit_code: i32, expected_stderr: String, expected_stdout: String, + expected_stderr_contains: String, assertions: Vec, assert_stdout: bool, assert_stderr: bool, @@ -101,9 +102,10 @@ impl TestBuilder { expected_exit_code: 0, expected_stderr: Default::default(), expected_stdout: Default::default(), + expected_stderr_contains: Default::default(), assertions: Default::default(), assert_stdout: true, - assert_stderr: true, + assert_stderr: false, } } @@ -168,6 +170,15 @@ impl TestBuilder { pub fn assert_stderr(&mut self, output: &str) -> &mut Self { self.expected_stderr.push_str(output); + self.assert_stderr = true; + self.expected_stderr_contains.clear(); + self + } + + pub fn assert_stderr_contains(&mut self, output: &str) -> &mut Self { + self.expected_stderr_contains.push_str(output); + self.assert_stderr = false; + self.expected_stderr.clear(); self } @@ -181,15 +192,16 @@ impl TestBuilder { self } - pub fn check_stderr(&mut self, check_stderr: bool) -> &mut Self { - self.assert_stderr = check_stderr; - self - } - pub fn assert_exists(&mut self, path: &str) -> &mut Self { self.ensure_temp_dir(); - self.assertions - .push(TestAssertion::FileExists(path.to_string())); + let temp_dir = if let Some(temp_dir) = &self.temp_dir { + temp_dir.cwd.display().to_string() + } else { + "NO_TEMP_DIR".to_string() + }; + self.assertions.push(TestAssertion::FileExists( + path.to_string().replace("$TEMP_DIR", &temp_dir), + )); self } @@ -210,6 +222,8 @@ impl TestBuilder { } pub async fn run(&mut self) { + std::env::set_var("NO_GRAPHICS", "1"); + let list = parse(&self.command).unwrap(); let cwd = if let Some(temp_dir) = &self.temp_dir { temp_dir.cwd.clone() @@ -223,6 +237,7 @@ impl TestBuilder { let (stderr, stderr_handle) = get_output_writer_and_handle(); let local_set = tokio::task::LocalSet::new(); + self.env_var("TEMP_DIR", &cwd.display().to_string()); let state = ShellState::new( self.env_vars.clone(), &cwd, @@ -236,13 +251,25 @@ impl TestBuilder { } else { "NO_TEMP_DIR".to_string() }; + let stderr_output = stderr_handle.await.unwrap(); if self.assert_stderr { assert_eq!( - stderr_handle.await.unwrap(), + stderr_output, self.expected_stderr.replace("$TEMP_DIR", &temp_dir), "\n\nFailed for: {}", self.command ); + } else if !self.expected_stderr_contains.is_empty() { + assert!( + stderr_output.contains( + &self + .expected_stderr_contains + .replace("$TEMP_DIR", &temp_dir) + ), + "\n\nFailed for: {}\nExpected stderr to contain: {}", + self.command, + self.expected_stderr_contains + ); } if self.assert_stdout { assert_eq!( @@ -261,8 +288,10 @@ impl TestBuilder { for assertion in &self.assertions { match assertion { TestAssertion::FileExists(path) => { + let path_to_check = cwd.join(path); + assert!( - cwd.join(path).exists(), + path_to_check.exists(), "\n\nFailed for: {}\nExpected '{}' to exist.", self.command, path,