From a7f43e82270e342e5bb7c5711f7998daa8cbd2d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Feb 2026 06:42:21 +0000 Subject: [PATCH] feat(builtins): implement file comparison test operators (-nt, -ot, -ef) Implement -nt (newer than), -ot (older than), and -ef (same file) operators for both [ ] test command and [[ ]] conditionals. Uses VFS metadata timestamps for -nt/-ot and canonical path comparison for -ef. 10 new spec tests. --- crates/bashkit/src/builtins/test.rs | 34 ++++++- crates/bashkit/src/interpreter/mod.rs | 29 ++++++ .../spec_cases/bash/test-operators.test.sh | 92 +++++++++++++++++++ specs/009-implementation-status.md | 8 +- 4 files changed, 155 insertions(+), 8 deletions(-) diff --git a/crates/bashkit/src/builtins/test.rs b/crates/bashkit/src/builtins/test.rs index 511b900f..423a119d 100644 --- a/crates/bashkit/src/builtins/test.rs +++ b/crates/bashkit/src/builtins/test.rs @@ -110,7 +110,7 @@ fn evaluate_expression<'a>( } 3 => { // Binary operators - evaluate_binary(&args[0], &args[1], &args[2]) + evaluate_binary(&args[0], &args[1], &args[2], fs).await } _ => false, } @@ -198,7 +198,7 @@ async fn evaluate_unary(op: &str, arg: &str, fs: &Arc) -> bool { } /// Evaluate a binary test expression -fn evaluate_binary(left: &str, op: &str, right: &str) -> bool { +async fn evaluate_binary(left: &str, op: &str, right: &str, fs: &Arc) -> bool { match op { // String comparisons "=" | "==" => left == right, @@ -214,8 +214,34 @@ fn evaluate_binary(left: &str, op: &str, right: &str) -> bool { "-gt" => parse_int(left) > parse_int(right), "-ge" => parse_int(left) >= parse_int(right), - // File comparisons (not implemented) - "-nt" | "-ot" | "-ef" => false, + // File comparisons + "-nt" => { + // file1 is newer than file2 + let left_meta = fs.stat(Path::new(left)).await; + let right_meta = fs.stat(Path::new(right)).await; + match (left_meta, right_meta) { + (Ok(lm), Ok(rm)) => lm.modified > rm.modified, + (Ok(_), Err(_)) => true, // left exists, right doesn't → left is newer + _ => false, + } + } + "-ot" => { + // file1 is older than file2 + let left_meta = fs.stat(Path::new(left)).await; + let right_meta = fs.stat(Path::new(right)).await; + match (left_meta, right_meta) { + (Ok(lm), Ok(rm)) => lm.modified < rm.modified, + (Err(_), Ok(_)) => true, // left doesn't exist, right does → left is older + _ => false, + } + } + "-ef" => { + // file1 and file2 refer to the same file (same path after resolution) + // In VFS without inodes, compare canonical paths + let left_path = super::resolve_path(&std::path::PathBuf::from("/"), left); + let right_path = super::resolve_path(&std::path::PathBuf::from("/"), right); + left_path == right_path + } _ => false, } diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 9b0d656f..a8f1d1c6 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -1227,6 +1227,35 @@ impl Interpreter { >= args[2].parse::().unwrap_or(0) } "=~" => self.regex_match(&args[0], &args[2]), + "-nt" => { + let lm = self.fs.stat(std::path::Path::new(&args[0])).await; + let rm = self.fs.stat(std::path::Path::new(&args[2])).await; + match (lm, rm) { + (Ok(l), Ok(r)) => l.modified > r.modified, + (Ok(_), Err(_)) => true, + _ => false, + } + } + "-ot" => { + let lm = self.fs.stat(std::path::Path::new(&args[0])).await; + let rm = self.fs.stat(std::path::Path::new(&args[2])).await; + match (lm, rm) { + (Ok(l), Ok(r)) => l.modified < r.modified, + (Err(_), Ok(_)) => true, + _ => false, + } + } + "-ef" => { + let lp = crate::builtins::resolve_path( + &std::path::PathBuf::from("/"), + &args[0], + ); + let rp = crate::builtins::resolve_path( + &std::path::PathBuf::from("/"), + &args[2], + ); + lp == rp + } _ => false, } } diff --git a/crates/bashkit/tests/spec_cases/bash/test-operators.test.sh b/crates/bashkit/tests/spec_cases/bash/test-operators.test.sh index bbdc45b9..aa98c272 100644 --- a/crates/bashkit/tests/spec_cases/bash/test-operators.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/test-operators.test.sh @@ -123,3 +123,95 @@ both true ### expect one true ### end + +### test_file_newer_than +# Test -nt (file1 newer than file2) +### bash_diff +echo "old" > /tmp/old.txt +sleep 0.01 +echo "new" > /tmp/new.txt +[ /tmp/new.txt -nt /tmp/old.txt ] && echo "newer" +### expect +newer +### end + +### test_file_older_than +# Test -ot (file1 older than file2) +### bash_diff +echo "first" > /tmp/first.txt +sleep 0.01 +echo "second" > /tmp/second.txt +[ /tmp/first.txt -ot /tmp/second.txt ] && echo "older" +### expect +older +### end + +### test_file_nt_nonexistent +# -nt returns true if left exists and right doesn't +echo "exists" > /tmp/exists_nt.txt +[ /tmp/exists_nt.txt -nt /tmp/nonexistent_nt ] && echo "newer" +### expect +newer +### end + +### test_file_ot_nonexistent +# -ot returns true if left doesn't exist and right does +echo "exists" > /tmp/exists_ot.txt +[ /tmp/nonexistent_ot -ot /tmp/exists_ot.txt ] && echo "older" +### expect +older +### end + +### test_file_ef_same_path +# -ef returns true for same file +echo "data" > /tmp/ef_test.txt +[ /tmp/ef_test.txt -ef /tmp/ef_test.txt ] && echo "same" +### expect +same +### end + +### test_file_ef_different_path +# -ef returns false for different files +echo "a" > /tmp/ef_a.txt +echo "b" > /tmp/ef_b.txt +[ /tmp/ef_a.txt -ef /tmp/ef_b.txt ] || echo "different" +### expect +different +### end + +### test_file_nt_both_nonexistent +# -nt returns false if both don't exist +[ /tmp/no1 -nt /tmp/no2 ] || echo "false" +### expect +false +### end + +### test_cond_nt +# [[ ]] also supports -nt +### bash_diff +echo "old" > /tmp/c_old.txt +sleep 0.01 +echo "new" > /tmp/c_new.txt +[[ /tmp/c_new.txt -nt /tmp/c_old.txt ]] && echo "newer" +### expect +newer +### end + +### test_cond_ot +# [[ ]] also supports -ot +### bash_diff +echo "first" > /tmp/c_first.txt +sleep 0.01 +echo "second" > /tmp/c_second.txt +[[ /tmp/c_first.txt -ot /tmp/c_second.txt ]] && echo "older" +### expect +older +### end + +### test_cond_ef +# [[ ]] also supports -ef +echo "data" > /tmp/c_ef.txt +[[ /tmp/c_ef.txt -ef /tmp/c_ef.txt ]] && echo "same" +### expect +same +### end diff --git a/specs/009-implementation-status.md b/specs/009-implementation-status.md index e3b472d6..7fde47fd 100644 --- a/specs/009-implementation-status.md +++ b/specs/009-implementation-status.md @@ -103,17 +103,17 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See ## Spec Test Coverage -**Total spec test cases:** 1389 (1384 pass, 5 skip) +**Total spec test cases:** 1399 (1394 pass, 5 skip) | Category | Cases | In CI | Pass | Skip | Notes | |----------|-------|-------|------|------|-------| -| Bash (core) | 971 | Yes | 966 | 5 | `bash_spec_tests` in CI | +| Bash (core) | 981 | Yes | 976 | 5 | `bash_spec_tests` in CI | | AWK | 96 | Yes | 96 | 0 | loops, arrays, -v, ternary, field assign, getline, %.6g | | Grep | 76 | Yes | 76 | 0 | -z, -r, -a, -b, -H, -h, -f, -P, --include, --exclude, binary detect | | Sed | 75 | Yes | 75 | 0 | hold space, change, regex ranges, -E | | JQ | 114 | Yes | 114 | 0 | reduce, walk, regex funcs, --arg/--argjson, combined flags, input/inputs, env | | Python | 57 | Yes | 57 | 0 | embedded Python (Monty) | -| **Total** | **1389** | **Yes** | **1384** | **5** | | +| **Total** | **1399** | **Yes** | **1394** | **5** | | ### Bash Spec Tests Breakdown @@ -154,7 +154,7 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See | sleep.test.sh | 6 | | | sortuniq.test.sh | 32 | sort and uniq, `-z` zero-terminated, `-m` merge | | source.test.sh | 21 | source/., function loading, PATH search, positional params | -| test-operators.test.sh | 17 | file/string tests | +| test-operators.test.sh | 27 | file/string tests, `-nt`/`-ot`/`-ef` file comparisons | | time.test.sh | 11 | Wall-clock only (user/sys always 0) | | timeout.test.sh | 17 | | | variables.test.sh | 97 | includes special vars, prefix env, PIPESTATUS, trap EXIT, `${var@Q}`, `\` line continuation, PWD/HOME/USER/HOSTNAME/BASH_VERSION/SECONDS, `set -x` xtrace, `shopt` builtin, nullglob, `set -o`/`set +o` display, `trap -p` |