Skip to main content

zsh/
cond.rs

1//! Conditional expression evaluation for zshrs
2//!
3//! Direct port from zsh/Src/cond.c
4//!
5//! Evaluates conditional expressions used in:
6//! - `[[ ... ]]` (zsh extended test)
7//! - `[ ... ]` and `test` (POSIX test)
8//!
9//! Supports:
10//! - File tests (-e, -f, -d, -r, -w, -x, etc.)
11//! - String tests (-n, -z, =, !=, <, >)
12//! - Numeric comparisons (-eq, -ne, -lt, -gt, -le, -ge)
13//! - Logical operators (!, &&, ||)
14//! - Pattern matching (=~, ==, !=)
15//! - File comparisons (-nt, -ot, -ef)
16
17use std::collections::HashMap;
18use std::fs::{self, Metadata};
19use std::os::unix::fs::MetadataExt;
20use std::path::Path;
21
22use crate::glob::pattern_match;
23
24/// Condition type codes matching zsh's COND_* constants
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum CondType {
27    // Logical operators
28    Not, // !
29    And, // &&
30    Or,  // ||
31
32    // String comparisons
33    StrEq,  // = or ==
34    StrDeq, // == (double equals)
35    StrNeq, // !=
36    StrLt,  // <
37    StrGt,  // >
38
39    // File comparisons
40    Nt, // -nt (newer than)
41    Ot, // -ot (older than)
42    Ef, // -ef (same file)
43
44    // Numeric comparisons
45    Eq, // -eq
46    Ne, // -ne
47    Lt, // -lt
48    Gt, // -gt
49    Le, // -le
50    Ge, // -ge
51
52    // Regex
53    Regex, // =~
54
55    // Unary file tests (single character codes)
56    FileTest(char),
57
58    // Module conditions (custom tests)
59    Mod,
60    Modi,
61}
62
63/// Result of condition evaluation
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum CondResult {
66    True,           // 0 - condition is true
67    False,          // 1 - condition is false
68    Error,          // 2 - syntax error
69    OptionNotExist, // 3 - option tested with -o does not exist
70}
71
72impl CondResult {
73    pub fn to_exit_code(self) -> i32 {
74        match self {
75            CondResult::True => 0,
76            CondResult::False => 1,
77            CondResult::Error => 2,
78            CondResult::OptionNotExist => 3,
79        }
80    }
81
82    pub fn from_bool(b: bool) -> Self {
83        if b {
84            CondResult::True
85        } else {
86            CondResult::False
87        }
88    }
89
90    pub fn negate(self) -> Self {
91        match self {
92            CondResult::True => CondResult::False,
93            CondResult::False => CondResult::True,
94            other => other,
95        }
96    }
97}
98
99/// Conditional expression evaluator
100pub struct CondEval<'a> {
101    /// Shell options (for -o test)
102    options: &'a HashMap<String, bool>,
103    /// Shell variables (for -v test)
104    variables: &'a HashMap<String, String>,
105    /// Whether we're in POSIX test mode ([ ] or test)
106    posix_mode: bool,
107    /// Enable tracing output
108    tracing: bool,
109}
110
111impl<'a> CondEval<'a> {
112    pub fn new(options: &'a HashMap<String, bool>, variables: &'a HashMap<String, String>) -> Self {
113        CondEval {
114            options,
115            variables,
116            posix_mode: false,
117            tracing: false,
118        }
119    }
120
121    pub fn with_posix_mode(mut self, posix: bool) -> Self {
122        self.posix_mode = posix;
123        self
124    }
125
126    pub fn with_tracing(mut self, tracing: bool) -> Self {
127        self.tracing = tracing;
128        self
129    }
130
131    /// Evaluate a parsed conditional expression
132    pub fn eval(&self, expr: &CondExpr) -> CondResult {
133        match expr {
134            CondExpr::Not(inner) => {
135                let result = self.eval(inner);
136                result.negate()
137            }
138
139            CondExpr::And(left, right) => {
140                let left_result = self.eval(left);
141                if left_result != CondResult::True {
142                    return left_result;
143                }
144                self.eval(right)
145            }
146
147            CondExpr::Or(left, right) => {
148                let left_result = self.eval(left);
149                if left_result == CondResult::True {
150                    return CondResult::True;
151                }
152                if left_result == CondResult::Error {
153                    return CondResult::Error;
154                }
155                self.eval(right)
156            }
157
158            CondExpr::Unary(op, arg) => self.eval_unary(*op, arg),
159
160            CondExpr::Binary(op, left, right) => self.eval_binary(*op, left, right),
161
162            CondExpr::Ternary(_, _, _, _) => CondResult::Error, // Not used in conditionals
163        }
164    }
165
166    fn eval_unary(&self, op: char, arg: &str) -> CondResult {
167        match op {
168            // File existence tests
169            'a' | 'e' => CondResult::from_bool(self.file_exists(arg)),
170            'b' => CondResult::from_bool(self.is_block_device(arg)),
171            'c' => CondResult::from_bool(self.is_char_device(arg)),
172            'd' => CondResult::from_bool(self.is_directory(arg)),
173            'f' => CondResult::from_bool(self.is_regular_file(arg)),
174            'g' => CondResult::from_bool(self.has_setgid(arg)),
175            'h' | 'L' => CondResult::from_bool(self.is_symlink(arg)),
176            'k' => CondResult::from_bool(self.has_sticky(arg)),
177            'p' => CondResult::from_bool(self.is_fifo(arg)),
178            'r' => CondResult::from_bool(self.is_readable(arg)),
179            's' => CondResult::from_bool(self.has_size(arg)),
180            'S' => CondResult::from_bool(self.is_socket(arg)),
181            'u' => CondResult::from_bool(self.has_setuid(arg)),
182            'w' => CondResult::from_bool(self.is_writable(arg)),
183            'x' => CondResult::from_bool(self.is_executable(arg)),
184            'O' => CondResult::from_bool(self.is_owned_by_euid(arg)),
185            'G' => CondResult::from_bool(self.is_owned_by_egid(arg)),
186            'N' => CondResult::from_bool(self.is_modified_since_read(arg)),
187
188            // String tests
189            'n' => CondResult::from_bool(!arg.is_empty()),
190            'z' => CondResult::from_bool(arg.is_empty()),
191
192            // Option test
193            'o' => self.test_option(arg),
194
195            // Variable test
196            'v' => CondResult::from_bool(self.variables.contains_key(arg)),
197
198            // TTY test
199            't' => {
200                if let Ok(fd) = arg.parse::<i32>() {
201                    CondResult::from_bool(unsafe { libc::isatty(fd) } != 0)
202                } else {
203                    CondResult::Error
204                }
205            }
206
207            _ => CondResult::Error,
208        }
209    }
210
211    fn eval_binary(&self, op: CondType, left: &str, right: &str) -> CondResult {
212        match op {
213            // String comparisons
214            CondType::StrEq | CondType::StrDeq => {
215                // In [[ ]], right side is a pattern
216                if !self.posix_mode {
217                    CondResult::from_bool(pattern_match(right, left, true, true))
218                } else {
219                    CondResult::from_bool(left == right)
220                }
221            }
222            CondType::StrNeq => {
223                if !self.posix_mode {
224                    CondResult::from_bool(!pattern_match(right, left, true, true))
225                } else {
226                    CondResult::from_bool(left != right)
227                }
228            }
229            CondType::StrLt => CondResult::from_bool(left < right),
230            CondType::StrGt => CondResult::from_bool(left > right),
231
232            // Numeric comparisons
233            CondType::Eq => self.numeric_compare(left, right, |a, b| a == b),
234            CondType::Ne => self.numeric_compare(left, right, |a, b| a != b),
235            CondType::Lt => self.numeric_compare(left, right, |a, b| a < b),
236            CondType::Gt => self.numeric_compare(left, right, |a, b| a > b),
237            CondType::Le => self.numeric_compare(left, right, |a, b| a <= b),
238            CondType::Ge => self.numeric_compare(left, right, |a, b| a >= b),
239
240            // File comparisons
241            CondType::Nt => self.file_newer_than(left, right),
242            CondType::Ot => self.file_older_than(left, right),
243            CondType::Ef => self.same_file(left, right),
244
245            // Regex match
246            CondType::Regex => self.regex_match(left, right),
247
248            _ => CondResult::Error,
249        }
250    }
251
252    // File test implementations
253
254    fn get_metadata(&self, path: &str) -> Option<Metadata> {
255        // Handle /dev/fd/N
256        if let Some(fd_str) = path.strip_prefix("/dev/fd/") {
257            if let Ok(fd) = fd_str.parse::<i32>() {
258                // Use fstat for /dev/fd/N
259                let mut stat: libc::stat = unsafe { std::mem::zeroed() };
260                if unsafe { libc::fstat(fd, &mut stat) } == 0 {
261                    // We can't easily convert libc::stat to std::fs::Metadata,
262                    // so fall back to regular stat
263                    return fs::metadata(path).ok();
264                }
265            }
266        }
267        fs::metadata(path).ok()
268    }
269
270    fn get_symlink_metadata(&self, path: &str) -> Option<Metadata> {
271        fs::symlink_metadata(path).ok()
272    }
273
274    fn file_exists(&self, path: &str) -> bool {
275        Path::new(path).exists()
276    }
277
278    fn is_block_device(&self, path: &str) -> bool {
279        self.get_metadata(path)
280            .map(|m| m.mode() & libc::S_IFMT as u32 == libc::S_IFBLK as u32)
281            .unwrap_or(false)
282    }
283
284    fn is_char_device(&self, path: &str) -> bool {
285        self.get_metadata(path)
286            .map(|m| m.mode() & libc::S_IFMT as u32 == libc::S_IFCHR as u32)
287            .unwrap_or(false)
288    }
289
290    fn is_directory(&self, path: &str) -> bool {
291        Path::new(path).is_dir()
292    }
293
294    fn is_regular_file(&self, path: &str) -> bool {
295        Path::new(path).is_file()
296    }
297
298    fn is_symlink(&self, path: &str) -> bool {
299        self.get_symlink_metadata(path)
300            .map(|m| m.file_type().is_symlink())
301            .unwrap_or(false)
302    }
303
304    fn is_fifo(&self, path: &str) -> bool {
305        self.get_metadata(path)
306            .map(|m| m.mode() & libc::S_IFMT as u32 == libc::S_IFIFO as u32)
307            .unwrap_or(false)
308    }
309
310    fn is_socket(&self, path: &str) -> bool {
311        self.get_metadata(path)
312            .map(|m| m.mode() & libc::S_IFMT as u32 == libc::S_IFSOCK as u32)
313            .unwrap_or(false)
314    }
315
316    fn has_setuid(&self, path: &str) -> bool {
317        self.get_metadata(path)
318            .map(|m| m.mode() & libc::S_ISUID as u32 != 0)
319            .unwrap_or(false)
320    }
321
322    fn has_setgid(&self, path: &str) -> bool {
323        self.get_metadata(path)
324            .map(|m| m.mode() & libc::S_ISGID as u32 != 0)
325            .unwrap_or(false)
326    }
327
328    fn has_sticky(&self, path: &str) -> bool {
329        self.get_metadata(path)
330            .map(|m| m.mode() & libc::S_ISVTX as u32 != 0)
331            .unwrap_or(false)
332    }
333
334    fn is_readable(&self, path: &str) -> bool {
335        use std::ffi::CString;
336        if let Ok(c_path) = CString::new(path) {
337            unsafe { libc::access(c_path.as_ptr(), libc::R_OK) == 0 }
338        } else {
339            fs::metadata(path).is_ok()
340        }
341    }
342
343    fn is_writable(&self, path: &str) -> bool {
344        use std::ffi::CString;
345        if let Ok(c_path) = CString::new(path) {
346            unsafe { libc::access(c_path.as_ptr(), libc::W_OK) == 0 }
347        } else {
348            self.get_metadata(path)
349                .map(|m| m.mode() & 0o200 != 0)
350                .unwrap_or(false)
351        }
352    }
353
354    fn is_executable(&self, path: &str) -> bool {
355        self.get_metadata(path)
356            .map(|m| {
357                let mode = m.mode();
358                // Check if any execute bit is set, or if it's a directory
359                (mode & 0o111 != 0) || (mode & libc::S_IFMT as u32 == libc::S_IFDIR as u32)
360            })
361            .unwrap_or(false)
362    }
363
364    fn has_size(&self, path: &str) -> bool {
365        self.get_metadata(path)
366            .map(|m| m.len() > 0)
367            .unwrap_or(false)
368    }
369
370    fn is_owned_by_euid(&self, path: &str) -> bool {
371        self.get_metadata(path)
372            .map(|m| m.uid() == unsafe { libc::geteuid() })
373            .unwrap_or(false)
374    }
375
376    fn is_owned_by_egid(&self, path: &str) -> bool {
377        self.get_metadata(path)
378            .map(|m| m.gid() == unsafe { libc::getegid() })
379            .unwrap_or(false)
380    }
381
382    fn is_modified_since_read(&self, path: &str) -> bool {
383        self.get_metadata(path)
384            .map(|m| m.mtime() >= m.atime())
385            .unwrap_or(false)
386    }
387
388    // Numeric comparison
389
390    fn numeric_compare<F>(&self, left: &str, right: &str, cmp: F) -> CondResult
391    where
392        F: Fn(f64, f64) -> bool,
393    {
394        let left_val = self.parse_number(left);
395        let right_val = self.parse_number(right);
396
397        match (left_val, right_val) {
398            (Some(l), Some(r)) => CondResult::from_bool(cmp(l, r)),
399            _ => CondResult::Error,
400        }
401    }
402
403    fn parse_number(&self, s: &str) -> Option<f64> {
404        // In POSIX mode, only base-10 integers
405        if self.posix_mode {
406            s.trim().parse::<i64>().ok().map(|i| i as f64)
407        } else {
408            // Try integer first, then float
409            if let Ok(i) = s.trim().parse::<i64>() {
410                Some(i as f64)
411            } else {
412                s.trim().parse::<f64>().ok()
413            }
414        }
415    }
416
417    // File comparisons
418
419    fn file_newer_than(&self, left: &str, right: &str) -> CondResult {
420        let left_meta = match self.get_metadata(left) {
421            Some(m) => m,
422            None => return CondResult::False,
423        };
424        let right_meta = match self.get_metadata(right) {
425            Some(m) => m,
426            None => return CondResult::False,
427        };
428
429        CondResult::from_bool(left_meta.mtime() > right_meta.mtime())
430    }
431
432    fn file_older_than(&self, left: &str, right: &str) -> CondResult {
433        let left_meta = match self.get_metadata(left) {
434            Some(m) => m,
435            None => return CondResult::False,
436        };
437        let right_meta = match self.get_metadata(right) {
438            Some(m) => m,
439            None => return CondResult::False,
440        };
441
442        CondResult::from_bool(left_meta.mtime() < right_meta.mtime())
443    }
444
445    fn same_file(&self, left: &str, right: &str) -> CondResult {
446        let left_meta = match self.get_metadata(left) {
447            Some(m) => m,
448            None => return CondResult::False,
449        };
450        let right_meta = match self.get_metadata(right) {
451            Some(m) => m,
452            None => return CondResult::False,
453        };
454
455        CondResult::from_bool(
456            left_meta.dev() == right_meta.dev() && left_meta.ino() == right_meta.ino(),
457        )
458    }
459
460    // Option test
461
462    fn test_option(&self, name: &str) -> CondResult {
463        // Single character option
464        if name.len() == 1 {
465            let ch = name.chars().next().unwrap();
466            if let Some(opt_name) = short_option_name(ch) {
467                if let Some(&val) = self.options.get(opt_name) {
468                    return CondResult::from_bool(val);
469                }
470            }
471        }
472
473        // Full option name
474        if let Some(&val) = self.options.get(name) {
475            CondResult::from_bool(val)
476        } else {
477            CondResult::OptionNotExist
478        }
479    }
480
481    // Regex match
482
483    fn regex_match(&self, text: &str, pattern: &str) -> CondResult {
484        #[cfg(feature = "regex")]
485        {
486            match regex::Regex::new(pattern) {
487                Ok(re) => CondResult::from_bool(re.is_match(text)),
488                Err(_) => CondResult::Error,
489            }
490        }
491        #[cfg(not(feature = "regex"))]
492        {
493            // Fallback: simple pattern match
494            CondResult::from_bool(pattern_match(pattern, text, true, true))
495        }
496    }
497}
498
499/// Map single-character option codes to option names
500fn short_option_name(c: char) -> Option<&'static str> {
501    Some(match c {
502        'a' => "allexport",
503        'B' => "braceccl",
504        'C' => "noclobber",
505        'e' => "errexit",
506        'f' => "noglob",
507        'g' => "histignorespace",
508        'h' => "hashcmds",
509        'H' => "histexpand",
510        'i' => "interactive",
511        'I' => "ignoreeof",
512        'j' => "monitor",
513        'k' => "keywordargs",
514        'l' => "login",
515        'm' => "monitor",
516        'n' => "noexec",
517        'p' => "privileged",
518        'P' => "physical",
519        'r' => "restricted",
520        's' => "stdin",
521        't' => "singlecommand",
522        'u' => "nounset",
523        'v' => "verbose",
524        'w' => "chaselinks",
525        'x' => "xtrace",
526        'X' => "listtypes",
527        'Y' => "menucomplete",
528        'Z' => "zle",
529        '0' => "correct",
530        '1' => "printexitvalue",
531        '2' => "autolist",
532        '3' => "autocontinue",
533        '4' => "autoparamslash",
534        '5' => "autopushd",
535        '6' => "autoremoveslash",
536        '7' => "bsdecho",
537        '8' => "nocaseglob",
538        '9' => "cdablevars",
539        _ => return None,
540    })
541}
542
543/// Parsed conditional expression
544#[derive(Debug, Clone)]
545pub enum CondExpr {
546    Not(Box<CondExpr>),
547    And(Box<CondExpr>, Box<CondExpr>),
548    Or(Box<CondExpr>, Box<CondExpr>),
549    Unary(char, String),
550    Binary(CondType, String, String),
551    Ternary(CondType, String, String, String),
552}
553
554/// Parser for conditional expressions
555pub struct CondParser<'a> {
556    tokens: Vec<&'a str>,
557    pos: usize,
558    posix_mode: bool,
559}
560
561impl<'a> CondParser<'a> {
562    pub fn new(tokens: Vec<&'a str>, posix_mode: bool) -> Self {
563        CondParser {
564            tokens,
565            pos: 0,
566            posix_mode,
567        }
568    }
569
570    pub fn parse(&mut self) -> Result<CondExpr, String> {
571        self.parse_or()
572    }
573
574    fn parse_or(&mut self) -> Result<CondExpr, String> {
575        let mut left = self.parse_and()?;
576
577        while self.match_token("||") || self.match_token("-o") {
578            let right = self.parse_and()?;
579            left = CondExpr::Or(Box::new(left), Box::new(right));
580        }
581
582        Ok(left)
583    }
584
585    fn parse_and(&mut self) -> Result<CondExpr, String> {
586        let mut left = self.parse_not()?;
587
588        while self.match_token("&&") || self.match_token("-a") {
589            let right = self.parse_not()?;
590            left = CondExpr::And(Box::new(left), Box::new(right));
591        }
592
593        Ok(left)
594    }
595
596    fn parse_not(&mut self) -> Result<CondExpr, String> {
597        if self.match_token("!") {
598            let inner = self.parse_not()?;
599            Ok(CondExpr::Not(Box::new(inner)))
600        } else {
601            self.parse_primary()
602        }
603    }
604
605    fn parse_primary(&mut self) -> Result<CondExpr, String> {
606        // Parenthesized expression
607        if self.match_token("(") {
608            let expr = self.parse_or()?;
609            if !self.match_token(")") {
610                return Err("missing )".to_string());
611            }
612            return Ok(expr);
613        }
614
615        // Check for unary operators
616        if let Some(tok) = self.peek() {
617            if tok.starts_with('-') && tok.len() == 2 {
618                let op = tok.chars().nth(1).unwrap();
619                // Check if this is a unary file/string test
620                if is_unary_op(op) {
621                    self.advance();
622                    let arg = self.expect_arg()?;
623                    return Ok(CondExpr::Unary(op, arg.to_string()));
624                }
625            }
626        }
627
628        // Binary expression: left op right
629        let left = self.expect_arg()?;
630
631        if let Some(op) = self.peek() {
632            if let Some(cond_type) = parse_binary_op(op) {
633                self.advance();
634                let right = self.expect_arg()?;
635                return Ok(CondExpr::Binary(
636                    cond_type,
637                    left.to_string(),
638                    right.to_string(),
639                ));
640            }
641        }
642
643        // Implicit -n test for non-empty string
644        Ok(CondExpr::Unary('n', left.to_string()))
645    }
646
647    fn peek(&self) -> Option<&'a str> {
648        self.tokens.get(self.pos).copied()
649    }
650
651    fn advance(&mut self) -> Option<&'a str> {
652        let tok = self.tokens.get(self.pos).copied();
653        self.pos += 1;
654        tok
655    }
656
657    fn match_token(&mut self, expected: &str) -> bool {
658        if self.peek() == Some(expected) {
659            self.advance();
660            true
661        } else {
662            false
663        }
664    }
665
666    fn expect_arg(&mut self) -> Result<&'a str, String> {
667        self.advance()
668            .ok_or_else(|| "expected argument".to_string())
669    }
670}
671
672fn is_unary_op(c: char) -> bool {
673    matches!(
674        c,
675        'a' | 'b'
676            | 'c'
677            | 'd'
678            | 'e'
679            | 'f'
680            | 'g'
681            | 'h'
682            | 'k'
683            | 'L'
684            | 'n'
685            | 'o'
686            | 'p'
687            | 'r'
688            | 's'
689            | 'S'
690            | 't'
691            | 'u'
692            | 'v'
693            | 'w'
694            | 'x'
695            | 'z'
696            | 'G'
697            | 'N'
698            | 'O'
699    )
700}
701
702fn parse_binary_op(s: &str) -> Option<CondType> {
703    Some(match s {
704        "=" | "==" => CondType::StrEq,
705        "!=" => CondType::StrNeq,
706        "<" => CondType::StrLt,
707        ">" => CondType::StrGt,
708        "-eq" => CondType::Eq,
709        "-ne" => CondType::Ne,
710        "-lt" => CondType::Lt,
711        "-gt" => CondType::Gt,
712        "-le" => CondType::Le,
713        "-ge" => CondType::Ge,
714        "-nt" => CondType::Nt,
715        "-ot" => CondType::Ot,
716        "-ef" => CondType::Ef,
717        "=~" => CondType::Regex,
718        _ => return None,
719    })
720}
721
722/// Convenience function to evaluate a test expression
723pub fn eval_test(
724    args: &[&str],
725    options: &HashMap<String, bool>,
726    variables: &HashMap<String, String>,
727    posix_mode: bool,
728) -> i32 {
729    // Handle empty args
730    if args.is_empty() {
731        return 1; // false
732    }
733
734    // Filter out [ and ] if present
735    let args: Vec<&str> = args
736        .iter()
737        .filter(|&s| *s != "[" && *s != "]" && *s != "[[" && *s != "]]")
738        .copied()
739        .collect();
740
741    if args.is_empty() {
742        return 1;
743    }
744
745    let mut parser = CondParser::new(args, posix_mode);
746    match parser.parse() {
747        Ok(expr) => {
748            let evaluator = CondEval::new(options, variables).with_posix_mode(posix_mode);
749            evaluator.eval(&expr).to_exit_code()
750        }
751        Err(_) => 2, // syntax error
752    }
753}
754
755#[cfg(test)]
756mod tests {
757    use super::*;
758    use std::fs::File;
759    use tempfile::TempDir;
760
761    fn empty_maps() -> (HashMap<String, bool>, HashMap<String, String>) {
762        (HashMap::new(), HashMap::new())
763    }
764
765    #[test]
766    fn test_string_empty() {
767        let (opts, vars) = empty_maps();
768        assert_eq!(eval_test(&["-z", ""], &opts, &vars, true), 0);
769        assert_eq!(eval_test(&["-z", "hello"], &opts, &vars, true), 1);
770        assert_eq!(eval_test(&["-n", "hello"], &opts, &vars, true), 0);
771        assert_eq!(eval_test(&["-n", ""], &opts, &vars, true), 1);
772    }
773
774    #[test]
775    fn test_string_compare() {
776        let (opts, vars) = empty_maps();
777        assert_eq!(eval_test(&["hello", "=", "hello"], &opts, &vars, true), 0);
778        assert_eq!(eval_test(&["hello", "!=", "world"], &opts, &vars, true), 0);
779        assert_eq!(eval_test(&["abc", "<", "def"], &opts, &vars, true), 0);
780        assert_eq!(eval_test(&["xyz", ">", "abc"], &opts, &vars, true), 0);
781    }
782
783    #[test]
784    fn test_numeric_compare() {
785        let (opts, vars) = empty_maps();
786        assert_eq!(eval_test(&["5", "-eq", "5"], &opts, &vars, true), 0);
787        assert_eq!(eval_test(&["5", "-ne", "3"], &opts, &vars, true), 0);
788        assert_eq!(eval_test(&["3", "-lt", "5"], &opts, &vars, true), 0);
789        assert_eq!(eval_test(&["5", "-gt", "3"], &opts, &vars, true), 0);
790        assert_eq!(eval_test(&["5", "-le", "5"], &opts, &vars, true), 0);
791        assert_eq!(eval_test(&["5", "-ge", "5"], &opts, &vars, true), 0);
792    }
793
794    #[test]
795    fn test_file_exists() {
796        let dir = TempDir::new().unwrap();
797        let file_path = dir.path().join("testfile");
798        File::create(&file_path).unwrap();
799
800        let (opts, vars) = empty_maps();
801        let path_str = file_path.to_str().unwrap();
802
803        assert_eq!(eval_test(&["-e", path_str], &opts, &vars, true), 0);
804        assert_eq!(eval_test(&["-f", path_str], &opts, &vars, true), 0);
805        assert_eq!(eval_test(&["-d", path_str], &opts, &vars, true), 1);
806    }
807
808    #[test]
809    fn test_directory() {
810        let dir = TempDir::new().unwrap();
811        let (opts, vars) = empty_maps();
812        let path_str = dir.path().to_str().unwrap();
813
814        assert_eq!(eval_test(&["-d", path_str], &opts, &vars, true), 0);
815        assert_eq!(eval_test(&["-f", path_str], &opts, &vars, true), 1);
816    }
817
818    #[test]
819    fn test_logical_not() {
820        let (opts, vars) = empty_maps();
821        assert_eq!(eval_test(&["!", "-z", "hello"], &opts, &vars, true), 0);
822        assert_eq!(eval_test(&["!", "-n", ""], &opts, &vars, true), 0);
823    }
824
825    #[test]
826    fn test_logical_and() {
827        let (opts, vars) = empty_maps();
828        assert_eq!(
829            eval_test(&["-n", "a", "-a", "-n", "b"], &opts, &vars, true),
830            0
831        );
832        assert_eq!(
833            eval_test(&["-n", "a", "-a", "-z", "b"], &opts, &vars, true),
834            1
835        );
836    }
837
838    #[test]
839    fn test_logical_or() {
840        let (opts, vars) = empty_maps();
841        assert_eq!(
842            eval_test(&["-z", "a", "-o", "-n", "b"], &opts, &vars, true),
843            0
844        );
845        assert_eq!(
846            eval_test(&["-z", "a", "-o", "-z", "b"], &opts, &vars, true),
847            1
848        );
849    }
850
851    #[test]
852    fn test_variable_exists() {
853        let opts = HashMap::new();
854        let mut vars = HashMap::new();
855        vars.insert("MYVAR".to_string(), "value".to_string());
856
857        assert_eq!(eval_test(&["-v", "MYVAR"], &opts, &vars, true), 0);
858        assert_eq!(eval_test(&["-v", "NOTEXIST"], &opts, &vars, true), 1);
859    }
860}