Skip to main content

rust_bash/commands/
mod.rs

1//! Command trait and built-in command implementations.
2
3pub(crate) mod awk;
4pub(crate) mod compression;
5pub(crate) mod diff_cmd;
6pub(crate) mod exec_cmds;
7pub(crate) mod file_ops;
8pub(crate) mod jq_cmd;
9pub(crate) mod navigation;
10#[cfg(feature = "network")]
11pub(crate) mod net;
12pub(crate) mod regex_util;
13pub(crate) mod sed;
14pub(crate) mod test_cmd;
15pub(crate) mod text;
16pub(crate) mod utils;
17
18use crate::error::RustBashError;
19use crate::interpreter::ExecutionLimits;
20use crate::network::NetworkPolicy;
21use crate::vfs::VirtualFs;
22use std::collections::HashMap;
23
24/// Result of executing a command.
25#[derive(Debug, Clone, Default, PartialEq, Eq)]
26pub struct CommandResult {
27    pub stdout: String,
28    pub stderr: String,
29    pub exit_code: i32,
30    /// Binary output for commands that produce non-text data (e.g. gzip).
31    /// When set, pipeline propagation uses this instead of `stdout`.
32    pub stdout_bytes: Option<Vec<u8>>,
33}
34
35/// Callback type for sub-command execution (e.g. `xargs`, `find -exec`).
36pub type ExecCallback<'a> = &'a dyn Fn(&str) -> Result<CommandResult, RustBashError>;
37
38/// Context passed to command execution.
39pub struct CommandContext<'a> {
40    pub fs: &'a dyn VirtualFs,
41    pub cwd: &'a str,
42    pub env: &'a HashMap<String, String>,
43    /// Full variable map with types — used for `test -v` array element checks.
44    pub variables: Option<&'a HashMap<String, crate::interpreter::Variable>>,
45    pub stdin: &'a str,
46    /// Binary input from a previous pipeline stage (e.g. gzip output).
47    /// Commands that handle binary input check this first, falling back to `stdin`.
48    pub stdin_bytes: Option<&'a [u8]>,
49    pub limits: &'a ExecutionLimits,
50    pub network_policy: &'a NetworkPolicy,
51    pub exec: Option<ExecCallback<'a>>,
52    /// Shell options (`set -o`), used by `test -o optname`.
53    pub shell_opts: Option<&'a crate::interpreter::ShellOpts>,
54}
55
56/// Support status of a command flag.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum FlagStatus {
59    /// Fully implemented with correct behavior.
60    Supported,
61    /// Accepted but behavior is stubbed/incomplete.
62    Stubbed,
63    /// Recognized but silently ignored.
64    Ignored,
65}
66
67/// Metadata about a single command flag.
68#[derive(Debug, Clone)]
69pub struct FlagInfo {
70    pub flag: &'static str,
71    pub description: &'static str,
72    pub status: FlagStatus,
73}
74
75/// Declarative metadata for a command, used by --help and the help builtin.
76pub struct CommandMeta {
77    pub name: &'static str,
78    pub synopsis: &'static str,
79    pub description: &'static str,
80    pub options: &'static [(&'static str, &'static str)],
81    pub supports_help_flag: bool,
82    pub flags: &'static [FlagInfo],
83}
84
85/// Format help text from `CommandMeta` for display via `--help`.
86pub fn format_help(meta: &CommandMeta) -> String {
87    let mut out = format!("Usage: {}\n\n{}\n", meta.synopsis, meta.description);
88    if !meta.options.is_empty() {
89        out.push_str("\nOptions:\n");
90        for (flag, desc) in meta.options {
91            out.push_str(&format!("  {:<20} {}\n", flag, desc));
92        }
93    }
94    if !meta.flags.is_empty() {
95        out.push_str("\nFlag support:\n");
96        for fi in meta.flags {
97            let status_label = match fi.status {
98                FlagStatus::Supported => "supported",
99                FlagStatus::Stubbed => "stubbed",
100                FlagStatus::Ignored => "ignored",
101            };
102            out.push_str(&format!(
103                "  {:<20} {} [{}]\n",
104                fi.flag, fi.description, status_label
105            ));
106        }
107    }
108    out
109}
110
111/// Standard error for unrecognized options, matching bash/GNU conventions.
112pub fn unknown_option(cmd: &str, option: &str) -> CommandResult {
113    let msg = if option.starts_with("--") {
114        format!("{}: unrecognized option '{}'\n", cmd, option)
115    } else {
116        format!(
117            "{}: invalid option -- '{}'\n",
118            cmd,
119            option.trim_start_matches('-')
120        )
121    };
122    CommandResult {
123        stderr: msg,
124        exit_code: 2,
125        ..Default::default()
126    }
127}
128
129/// Trait for commands that can be registered and executed.
130pub trait VirtualCommand: Send + Sync {
131    fn name(&self) -> &str;
132    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult;
133    fn meta(&self) -> Option<&'static CommandMeta> {
134        None
135    }
136}
137
138// ── Built-in command implementations ─────────────────────────────────
139
140/// The `echo` command: prints arguments to stdout.
141pub struct EchoCommand;
142
143static ECHO_META: CommandMeta = CommandMeta {
144    name: "echo",
145    synopsis: "echo [-neE] [string ...]",
146    description: "Write arguments to standard output.",
147    options: &[
148        ("-n", "do not output the trailing newline"),
149        ("-e", "enable interpretation of backslash escapes"),
150        ("-E", "disable interpretation of backslash escapes"),
151    ],
152    supports_help_flag: false,
153    flags: &[],
154};
155
156impl VirtualCommand for EchoCommand {
157    fn name(&self) -> &str {
158        "echo"
159    }
160
161    fn meta(&self) -> Option<&'static CommandMeta> {
162        Some(&ECHO_META)
163    }
164
165    fn execute(&self, args: &[String], _ctx: &CommandContext) -> CommandResult {
166        let mut no_newline = false;
167        let mut interpret_escapes = false;
168        let mut arg_start = 0;
169
170        for (i, arg) in args.iter().enumerate() {
171            if arg.starts_with('-')
172                && arg.len() > 1
173                && arg[1..].chars().all(|c| matches!(c, 'n' | 'e' | 'E'))
174            {
175                for c in arg[1..].chars() {
176                    match c {
177                        'n' => no_newline = true,
178                        'e' => interpret_escapes = true,
179                        'E' => interpret_escapes = false,
180                        _ => unreachable!(),
181                    }
182                }
183                arg_start = i + 1;
184            } else {
185                break;
186            }
187        }
188
189        let text = args[arg_start..].join(" ");
190        let (output, suppress_newline) = if interpret_escapes {
191            interpret_echo_escapes(&text)
192        } else {
193            (text, false)
194        };
195
196        let stdout = if no_newline || suppress_newline {
197            output
198        } else {
199            format!("{output}\n")
200        };
201
202        CommandResult {
203            stdout,
204            stderr: String::new(),
205            exit_code: 0,
206            stdout_bytes: None,
207        }
208    }
209}
210
211fn interpret_echo_escapes(s: &str) -> (String, bool) {
212    let mut result = String::with_capacity(s.len());
213    let chars: Vec<char> = s.chars().collect();
214    let mut i = 0;
215    while i < chars.len() {
216        if chars[i] == '\\' && i + 1 < chars.len() {
217            i += 1;
218            match chars[i] {
219                'n' => result.push('\n'),
220                't' => result.push('\t'),
221                '\\' => result.push('\\'),
222                'a' => result.push('\x07'),
223                'b' => result.push('\x08'),
224                'f' => result.push('\x0C'),
225                'r' => result.push('\r'),
226                'v' => result.push('\x0B'),
227                'e' | 'E' => result.push('\x1B'),
228                'c' => return (result, true),
229                '0' => {
230                    // \0NNN — octal (up to 3 octal digits after the 0)
231                    let mut val: u32 = 0;
232                    let mut count = 0;
233                    while count < 3
234                        && i + 1 < chars.len()
235                        && chars[i + 1] >= '0'
236                        && chars[i + 1] <= '7'
237                    {
238                        i += 1;
239                        val = val * 8 + (chars[i] as u32 - '0' as u32);
240                        count += 1;
241                    }
242                    if let Some(c) = char::from_u32(val) {
243                        result.push(c);
244                    }
245                }
246                'x' => {
247                    // \xHH — hex escape (up to 2 hex digits)
248                    let mut hex = String::new();
249                    while hex.len() < 2 && i + 1 < chars.len() && chars[i + 1].is_ascii_hexdigit() {
250                        i += 1;
251                        hex.push(chars[i]);
252                    }
253                    if hex.is_empty() {
254                        result.push('\\');
255                        result.push('x');
256                    } else if let Some(c) =
257                        u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32)
258                    {
259                        result.push(c);
260                    }
261                }
262                'u' => {
263                    // \uHHHH — unicode (up to 4 hex digits)
264                    let mut hex = String::new();
265                    while hex.len() < 4 && i + 1 < chars.len() && chars[i + 1].is_ascii_hexdigit() {
266                        i += 1;
267                        hex.push(chars[i]);
268                    }
269                    if hex.is_empty() {
270                        result.push('\\');
271                        result.push('u');
272                    } else if let Some(c) =
273                        u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32)
274                    {
275                        result.push(c);
276                    }
277                }
278                'U' => {
279                    // \UHHHHHHHH — unicode (up to 8 hex digits)
280                    let mut hex = String::new();
281                    while hex.len() < 8 && i + 1 < chars.len() && chars[i + 1].is_ascii_hexdigit() {
282                        i += 1;
283                        hex.push(chars[i]);
284                    }
285                    if hex.is_empty() {
286                        result.push('\\');
287                        result.push('U');
288                    } else if let Some(c) =
289                        u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32)
290                    {
291                        result.push(c);
292                    }
293                }
294                other => {
295                    result.push('\\');
296                    result.push(other);
297                }
298            }
299        } else {
300            result.push(chars[i]);
301        }
302        i += 1;
303    }
304    (result, false)
305}
306
307/// The `true` command: always succeeds (exit code 0).
308pub struct TrueCommand;
309
310static TRUE_META: CommandMeta = CommandMeta {
311    name: "true",
312    synopsis: "true",
313    description: "Do nothing, successfully.",
314    options: &[],
315    supports_help_flag: false,
316    flags: &[],
317};
318
319impl VirtualCommand for TrueCommand {
320    fn name(&self) -> &str {
321        "true"
322    }
323
324    fn meta(&self) -> Option<&'static CommandMeta> {
325        Some(&TRUE_META)
326    }
327
328    fn execute(&self, _args: &[String], _ctx: &CommandContext) -> CommandResult {
329        CommandResult::default()
330    }
331}
332
333/// The `false` command: always fails (exit code 1).
334pub struct FalseCommand;
335
336static FALSE_META: CommandMeta = CommandMeta {
337    name: "false",
338    synopsis: "false",
339    description: "Do nothing, unsuccessfully.",
340    options: &[],
341    supports_help_flag: false,
342    flags: &[],
343};
344
345impl VirtualCommand for FalseCommand {
346    fn name(&self) -> &str {
347        "false"
348    }
349
350    fn meta(&self) -> Option<&'static CommandMeta> {
351        Some(&FALSE_META)
352    }
353
354    fn execute(&self, _args: &[String], _ctx: &CommandContext) -> CommandResult {
355        CommandResult {
356            exit_code: 1,
357            ..CommandResult::default()
358        }
359    }
360}
361
362/// The `cat` command: concatenate files and/or stdin.
363pub struct CatCommand;
364
365static CAT_META: CommandMeta = CommandMeta {
366    name: "cat",
367    synopsis: "cat [-n] [FILE ...]",
368    description: "Concatenate files and print on standard output.",
369    options: &[("-n, --number", "number all output lines")],
370    supports_help_flag: true,
371    flags: &[],
372};
373
374impl VirtualCommand for CatCommand {
375    fn name(&self) -> &str {
376        "cat"
377    }
378
379    fn meta(&self) -> Option<&'static CommandMeta> {
380        Some(&CAT_META)
381    }
382
383    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {
384        let mut number_lines = false;
385        let mut files: Vec<&str> = Vec::new();
386
387        for arg in args {
388            if arg == "-n" || arg == "--number" {
389                number_lines = true;
390            } else if arg == "-" {
391                files.push("-");
392            } else if arg.starts_with('-') && arg.len() > 1 {
393                // Unknown flags — ignore for compatibility
394            } else {
395                files.push(arg);
396            }
397        }
398
399        // No files specified → read from stdin
400        if files.is_empty() {
401            files.push("-");
402        }
403
404        let mut output = String::new();
405        let mut stderr = String::new();
406        let mut exit_code = 0;
407
408        for file in &files {
409            let content = if *file == "-" || *file == "/dev/stdin" {
410                ctx.stdin.to_string()
411            } else if *file == "/dev/null" || *file == "/dev/zero" || *file == "/dev/full" {
412                String::new()
413            } else {
414                let path = if file.starts_with('/') {
415                    std::path::PathBuf::from(file)
416                } else {
417                    std::path::PathBuf::from(ctx.cwd).join(file)
418                };
419                match ctx.fs.read_file(&path) {
420                    Ok(bytes) => String::from_utf8_lossy(&bytes).to_string(),
421                    Err(e) => {
422                        stderr.push_str(&format!("cat: {file}: {e}\n"));
423                        exit_code = 1;
424                        continue;
425                    }
426                }
427            };
428
429            if number_lines {
430                let lines: Vec<&str> = content.split('\n').collect();
431                let line_count = if content.ends_with('\n') && lines.last() == Some(&"") {
432                    lines.len() - 1
433                } else {
434                    lines.len()
435                };
436                for (i, line) in lines.iter().take(line_count).enumerate() {
437                    output.push_str(&format!("     {}\t{}", i + 1, line));
438                    if i < line_count - 1 || content.ends_with('\n') {
439                        output.push('\n');
440                    }
441                }
442            } else {
443                output.push_str(&content);
444            }
445        }
446
447        CommandResult {
448            stdout: output,
449            stderr,
450            exit_code,
451            stdout_bytes: None,
452        }
453    }
454}
455
456/// The `pwd` command: print working directory.
457pub struct PwdCommand;
458
459static PWD_META: CommandMeta = CommandMeta {
460    name: "pwd",
461    synopsis: "pwd",
462    description: "Print the current working directory.",
463    options: &[],
464    supports_help_flag: true,
465    flags: &[],
466};
467
468impl VirtualCommand for PwdCommand {
469    fn name(&self) -> &str {
470        "pwd"
471    }
472
473    fn meta(&self) -> Option<&'static CommandMeta> {
474        Some(&PWD_META)
475    }
476
477    fn execute(&self, _args: &[String], ctx: &CommandContext) -> CommandResult {
478        CommandResult {
479            stdout: format!("{}\n", ctx.cwd),
480            stderr: String::new(),
481            exit_code: 0,
482            stdout_bytes: None,
483        }
484    }
485}
486
487/// The `touch` command: create empty file or update mtime.
488pub struct TouchCommand;
489
490static TOUCH_META: CommandMeta = CommandMeta {
491    name: "touch",
492    synopsis: "touch FILE ...",
493    description: "Update file access and modification times, creating files if needed.",
494    options: &[],
495    supports_help_flag: true,
496    flags: &[],
497};
498
499impl VirtualCommand for TouchCommand {
500    fn name(&self) -> &str {
501        "touch"
502    }
503
504    fn meta(&self) -> Option<&'static CommandMeta> {
505        Some(&TOUCH_META)
506    }
507
508    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {
509        let mut stderr = String::new();
510        let mut exit_code = 0;
511
512        // Parse args: skip flags before `--`, treat everything after `--` as filenames
513        let mut files: Vec<&str> = Vec::new();
514        let mut past_options = false;
515        for arg in args {
516            if past_options {
517                files.push(arg.as_str());
518            } else if arg == "--" {
519                past_options = true;
520            } else if arg.starts_with('-') && arg.len() > 1 {
521                // skip known flags (e.g. -c, -m, -a, etc.)
522                continue;
523            } else {
524                files.push(arg.as_str());
525            }
526        }
527
528        if files.is_empty() {
529            return CommandResult {
530                stdout: String::new(),
531                stderr: "touch: missing file operand\n".to_string(),
532                exit_code: 1,
533                stdout_bytes: None,
534            };
535        }
536
537        for file in files {
538            let path = if file.starts_with('/') {
539                std::path::PathBuf::from(file)
540            } else {
541                std::path::PathBuf::from(ctx.cwd).join(file)
542            };
543
544            if ctx.fs.exists(&path) {
545                // Update mtime
546                if let Err(e) = ctx.fs.utimes(&path, crate::platform::SystemTime::now()) {
547                    stderr.push_str(&format!("touch: cannot touch '{}': {}\n", file, e));
548                    exit_code = 1;
549                }
550            } else {
551                // Create empty file
552                if let Err(e) = ctx.fs.write_file(&path, b"") {
553                    stderr.push_str(&format!("touch: cannot touch '{}': {}\n", file, e));
554                    exit_code = 1;
555                }
556            }
557        }
558
559        CommandResult {
560            stdout: String::new(),
561            stderr,
562            exit_code,
563            stdout_bytes: None,
564        }
565    }
566}
567
568/// The `mkdir` command: create directories (`-p` for parents).
569pub struct MkdirCommand;
570
571static MKDIR_META: CommandMeta = CommandMeta {
572    name: "mkdir",
573    synopsis: "mkdir [-p] DIRECTORY ...",
574    description: "Create directories.",
575    options: &[("-p, --parents", "create parent directories as needed")],
576    supports_help_flag: true,
577    flags: &[],
578};
579
580impl VirtualCommand for MkdirCommand {
581    fn name(&self) -> &str {
582        "mkdir"
583    }
584
585    fn meta(&self) -> Option<&'static CommandMeta> {
586        Some(&MKDIR_META)
587    }
588
589    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {
590        let mut parents = false;
591        let mut dirs: Vec<&str> = Vec::new();
592        let mut stderr = String::new();
593        let mut exit_code = 0;
594
595        for arg in args {
596            if arg == "-p" || arg == "--parents" {
597                parents = true;
598            } else if arg.starts_with('-') {
599                // skip unknown flags
600            } else {
601                dirs.push(arg);
602            }
603        }
604
605        if dirs.is_empty() {
606            return CommandResult {
607                stdout: String::new(),
608                stderr: "mkdir: missing operand\n".to_string(),
609                exit_code: 1,
610                stdout_bytes: None,
611            };
612        }
613
614        for dir in dirs {
615            let path = if dir.starts_with('/') {
616                std::path::PathBuf::from(dir)
617            } else {
618                std::path::PathBuf::from(ctx.cwd).join(dir)
619            };
620
621            let result = if parents {
622                ctx.fs.mkdir_p(&path)
623            } else {
624                ctx.fs.mkdir(&path)
625            };
626
627            if let Err(e) = result {
628                stderr.push_str(&format!(
629                    "mkdir: cannot create directory '{}': {}\n",
630                    dir, e
631                ));
632                exit_code = 1;
633            }
634        }
635
636        CommandResult {
637            stdout: String::new(),
638            stderr,
639            exit_code,
640            stdout_bytes: None,
641        }
642    }
643}
644
645/// The `ls` command: list directory contents.
646pub struct LsCommand;
647
648static LS_FLAGS: &[FlagInfo] = &[
649    FlagInfo {
650        flag: "-a",
651        description: "show hidden entries",
652        status: FlagStatus::Supported,
653    },
654    FlagInfo {
655        flag: "-l",
656        description: "long listing format",
657        status: FlagStatus::Supported,
658    },
659    FlagInfo {
660        flag: "-1",
661        description: "one entry per line",
662        status: FlagStatus::Supported,
663    },
664    FlagInfo {
665        flag: "-R",
666        description: "recursive listing",
667        status: FlagStatus::Supported,
668    },
669    FlagInfo {
670        flag: "-t",
671        description: "sort by modification time",
672        status: FlagStatus::Ignored,
673    },
674    FlagInfo {
675        flag: "-S",
676        description: "sort by file size",
677        status: FlagStatus::Ignored,
678    },
679    FlagInfo {
680        flag: "-h",
681        description: "human-readable sizes",
682        status: FlagStatus::Ignored,
683    },
684];
685
686static LS_META: CommandMeta = CommandMeta {
687    name: "ls",
688    synopsis: "ls [-alR1] [FILE ...]",
689    description: "List directory contents.",
690    options: &[
691        ("-a", "do not ignore entries starting with ."),
692        ("-l", "use a long listing format"),
693        ("-1", "list one file per line"),
694        ("-R", "list subdirectories recursively"),
695    ],
696    supports_help_flag: true,
697    flags: LS_FLAGS,
698};
699
700impl VirtualCommand for LsCommand {
701    fn name(&self) -> &str {
702        "ls"
703    }
704
705    fn meta(&self) -> Option<&'static CommandMeta> {
706        Some(&LS_META)
707    }
708
709    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {
710        let mut show_all = false;
711        let mut long_format = false;
712        let mut one_per_line = false;
713        let mut recursive = false;
714        let mut targets: Vec<&str> = Vec::new();
715
716        for arg in args {
717            if arg.starts_with('-') && arg.len() > 1 && !arg.starts_with("--") {
718                for c in arg[1..].chars() {
719                    match c {
720                        'a' => show_all = true,
721                        'l' => long_format = true,
722                        '1' => one_per_line = true,
723                        'R' => recursive = true,
724                        _ => {}
725                    }
726                }
727            } else {
728                targets.push(arg);
729            }
730        }
731
732        if targets.is_empty() {
733            targets.push(".");
734        }
735
736        let opts = LsOptions {
737            show_all,
738            long_format,
739            one_per_line,
740            recursive,
741        };
742        let mut out = LsOutput {
743            stdout: String::new(),
744            stderr: String::new(),
745            exit_code: 0,
746        };
747        let multi_target = targets.len() > 1 || recursive;
748
749        for (idx, target) in targets.iter().enumerate() {
750            let path = if *target == "." {
751                std::path::PathBuf::from(ctx.cwd)
752            } else if target.starts_with('/') {
753                std::path::PathBuf::from(target)
754            } else {
755                std::path::PathBuf::from(ctx.cwd).join(target)
756            };
757
758            if idx > 0 {
759                out.stdout.push('\n');
760            }
761
762            ls_dir(ctx, &path, target, &opts, multi_target, &mut out);
763        }
764
765        CommandResult {
766            stdout: out.stdout,
767            stderr: out.stderr,
768            exit_code: out.exit_code,
769            stdout_bytes: None,
770        }
771    }
772}
773
774struct LsOptions {
775    show_all: bool,
776    long_format: bool,
777    one_per_line: bool,
778    recursive: bool,
779}
780
781struct LsOutput {
782    stdout: String,
783    stderr: String,
784    exit_code: i32,
785}
786
787fn ls_dir(
788    ctx: &CommandContext,
789    path: &std::path::Path,
790    display_name: &str,
791    opts: &LsOptions,
792    show_header: bool,
793    out: &mut LsOutput,
794) {
795    let entries = match ctx.fs.readdir(path) {
796        Ok(e) => e,
797        Err(e) => {
798            out.stderr
799                .push_str(&format!("ls: cannot access '{}': {}\n", display_name, e));
800            out.exit_code = 2;
801            return;
802        }
803    };
804
805    if show_header {
806        out.stdout.push_str(&format!("{}:\n", display_name));
807    }
808
809    let mut names: Vec<(String, crate::vfs::NodeType)> = entries
810        .iter()
811        .filter(|e| opts.show_all || !e.name.starts_with('.'))
812        .map(|e| (e.name.clone(), e.node_type))
813        .collect();
814    names.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
815
816    if opts.long_format {
817        for (name, node_type) in &names {
818            let child_path = path.join(name);
819            let meta = ctx.fs.stat(&child_path);
820            let mode = match meta {
821                Ok(m) => m.mode,
822                Err(_) => 0o644,
823            };
824            let type_char = match node_type {
825                crate::vfs::NodeType::Directory => 'd',
826                crate::vfs::NodeType::Symlink => 'l',
827                crate::vfs::NodeType::File => '-',
828            };
829            out.stdout
830                .push_str(&format!("{}{} {}\n", type_char, format_mode(mode), name));
831        }
832    } else if opts.one_per_line {
833        for (name, _) in &names {
834            out.stdout.push_str(name);
835            out.stdout.push('\n');
836        }
837    } else {
838        // Default: space-separated on one line
839        let name_strs: Vec<&str> = names.iter().map(|(n, _)| n.as_str()).collect();
840        if !name_strs.is_empty() {
841            out.stdout.push_str(&name_strs.join("  "));
842            out.stdout.push('\n');
843        }
844    }
845
846    if opts.recursive {
847        let subdirs: Vec<(String, std::path::PathBuf)> = names
848            .iter()
849            .filter(|(_, t)| matches!(t, crate::vfs::NodeType::Directory))
850            .map(|(n, _)| (n.clone(), path.join(n)))
851            .collect();
852
853        for (name, subpath) in subdirs {
854            out.stdout.push('\n');
855            let sub_display = if display_name == "." {
856                format!("./{}", name)
857            } else {
858                format!("{}/{}", display_name, name)
859            };
860            ls_dir(ctx, &subpath, &sub_display, opts, true, out);
861        }
862    }
863}
864
865fn format_mode(mode: u32) -> String {
866    let mut s = String::with_capacity(9);
867    let flags = [
868        (0o400, 'r'),
869        (0o200, 'w'),
870        (0o100, 'x'),
871        (0o040, 'r'),
872        (0o020, 'w'),
873        (0o010, 'x'),
874        (0o004, 'r'),
875        (0o002, 'w'),
876        (0o001, 'x'),
877    ];
878    for (bit, ch) in flags {
879        s.push(if mode & bit != 0 { ch } else { '-' });
880    }
881    s
882}
883
884/// The `test` command: evaluate conditional expressions.
885pub struct TestCommand;
886
887static TEST_META: CommandMeta = CommandMeta {
888    name: "test",
889    synopsis: "test EXPRESSION",
890    description: "Evaluate conditional expression.",
891    options: &[
892        ("-e FILE", "FILE exists"),
893        ("-f FILE", "FILE exists and is a regular file"),
894        ("-d FILE", "FILE exists and is a directory"),
895        ("-z STRING", "the length of STRING is zero"),
896        ("-n STRING", "the length of STRING is nonzero"),
897        ("s1 = s2", "the strings are equal"),
898        ("s1 != s2", "the strings are not equal"),
899        ("n1 -eq n2", "integers are equal"),
900        ("n1 -lt n2", "first integer is less than second"),
901    ],
902    supports_help_flag: false,
903    flags: &[],
904};
905
906impl VirtualCommand for TestCommand {
907    fn name(&self) -> &str {
908        "test"
909    }
910
911    fn meta(&self) -> Option<&'static CommandMeta> {
912        Some(&TEST_META)
913    }
914
915    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {
916        test_cmd::evaluate_test_args(args, ctx)
917    }
918}
919
920/// The `[` command: evaluate conditional expressions (requires closing `]`).
921pub struct BracketCommand;
922
923static BRACKET_META: CommandMeta = CommandMeta {
924    name: "[",
925    synopsis: "[ EXPRESSION ]",
926    description: "Evaluate conditional expression (synonym for test).",
927    options: &[],
928    supports_help_flag: false,
929    flags: &[],
930};
931
932impl VirtualCommand for BracketCommand {
933    fn name(&self) -> &str {
934        "["
935    }
936
937    fn meta(&self) -> Option<&'static CommandMeta> {
938        Some(&BRACKET_META)
939    }
940
941    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {
942        if args.is_empty() || args.last().map(|s| s.as_str()) != Some("]") {
943            return CommandResult {
944                stderr: "[: missing ']'\n".to_string(),
945                exit_code: 2,
946                ..CommandResult::default()
947            };
948        }
949        // Strip the closing ]
950        test_cmd::evaluate_test_args(&args[..args.len() - 1], ctx)
951    }
952}
953
954/// `fgrep` — alias for `grep -F`.
955pub struct FgrepCommand;
956
957static FGREP_META: CommandMeta = CommandMeta {
958    name: "fgrep",
959    synopsis: "fgrep [OPTIONS] PATTERN [FILE ...]",
960    description: "Equivalent to grep -F (fixed-string search).",
961    options: &[],
962    supports_help_flag: true,
963    flags: &[],
964};
965
966impl VirtualCommand for FgrepCommand {
967    fn name(&self) -> &str {
968        "fgrep"
969    }
970
971    fn meta(&self) -> Option<&'static CommandMeta> {
972        Some(&FGREP_META)
973    }
974
975    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {
976        let mut new_args = vec!["-F".to_string()];
977        new_args.extend(args.iter().cloned());
978        text::GrepCommand.execute(&new_args, ctx)
979    }
980}
981
982/// `egrep` — alias for `grep -E`.
983pub struct EgrepCommand;
984
985static EGREP_META: CommandMeta = CommandMeta {
986    name: "egrep",
987    synopsis: "egrep [OPTIONS] PATTERN [FILE ...]",
988    description: "Equivalent to grep -E (extended regexp search).",
989    options: &[],
990    supports_help_flag: true,
991    flags: &[],
992};
993
994impl VirtualCommand for EgrepCommand {
995    fn name(&self) -> &str {
996        "egrep"
997    }
998
999    fn meta(&self) -> Option<&'static CommandMeta> {
1000        Some(&EGREP_META)
1001    }
1002
1003    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {
1004        let mut new_args = vec!["-E".to_string()];
1005        new_args.extend(args.iter().cloned());
1006        text::GrepCommand.execute(&new_args, ctx)
1007    }
1008}
1009
1010/// Oils test helper: `argv.py` prints arguments as a Python list.
1011///
1012/// Equivalent to `python2 -c 'import sys; print(sys.argv[1:])'`.
1013struct ArgvPyCommand;
1014
1015static ARGV_PY_META: CommandMeta = CommandMeta {
1016    name: "argv.py",
1017    synopsis: "argv.py [arg ...]",
1018    description: "Print arguments as a Python list (Oils test helper).",
1019    options: &[],
1020    supports_help_flag: false,
1021    flags: &[],
1022};
1023
1024impl VirtualCommand for ArgvPyCommand {
1025    fn name(&self) -> &str {
1026        "argv.py"
1027    }
1028
1029    fn meta(&self) -> Option<&'static CommandMeta> {
1030        Some(&ARGV_PY_META)
1031    }
1032
1033    fn execute(&self, args: &[String], _ctx: &CommandContext) -> CommandResult {
1034        // Python repr format: ['a', 'b'] — matches Python 2 list repr.
1035        // If a string contains single quotes but no double quotes, use double quotes.
1036        let parts: Vec<String> = args.iter().map(|a| python_repr_string(a)).collect();
1037        CommandResult {
1038            stdout: format!("[{}]\n", parts.join(", ")),
1039            ..Default::default()
1040        }
1041    }
1042}
1043
1044/// Produce a Python-style repr of a string, matching Python 2 behavior.
1045fn python_repr_string(s: &str) -> String {
1046    if s.contains('\'') && !s.contains('"') {
1047        // Use double quotes: "it's"
1048        let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
1049        format!("\"{escaped}\"")
1050    } else {
1051        // Use single quotes: 'hello'
1052        let escaped = s.replace('\\', "\\\\").replace('\'', "\\'");
1053        format!("'{escaped}'")
1054    }
1055}
1056
1057/// Oils test helper: `printenv.py` prints specified environment variables.
1058struct PrintenvPyCommand;
1059
1060static PRINTENV_PY_META: CommandMeta = CommandMeta {
1061    name: "printenv.py",
1062    synopsis: "printenv.py [NAME ...]",
1063    description: "Print environment variables or 'None' (Oils test helper).",
1064    options: &[],
1065    supports_help_flag: false,
1066    flags: &[],
1067};
1068
1069impl VirtualCommand for PrintenvPyCommand {
1070    fn name(&self) -> &str {
1071        "printenv.py"
1072    }
1073
1074    fn meta(&self) -> Option<&'static CommandMeta> {
1075        Some(&PRINTENV_PY_META)
1076    }
1077
1078    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {
1079        let mut out = String::new();
1080        for name in args {
1081            match ctx.env.get(name.as_str()) {
1082                Some(val) => out.push_str(&format!("{val}\n")),
1083                None => out.push_str("None\n"),
1084            }
1085        }
1086        CommandResult {
1087            stdout: out,
1088            ..Default::default()
1089        }
1090    }
1091}
1092
1093/// Register the default set of commands.
1094pub fn register_default_commands() -> HashMap<String, Box<dyn VirtualCommand>> {
1095    let mut commands: HashMap<String, Box<dyn VirtualCommand>> = HashMap::new();
1096    let defaults: Vec<Box<dyn VirtualCommand>> = vec![
1097        Box::new(EchoCommand),
1098        Box::new(TrueCommand),
1099        Box::new(FalseCommand),
1100        Box::new(CatCommand),
1101        Box::new(PwdCommand),
1102        Box::new(TouchCommand),
1103        Box::new(MkdirCommand),
1104        Box::new(LsCommand),
1105        Box::new(TestCommand),
1106        Box::new(BracketCommand),
1107        // Phase 10a: file operations
1108        Box::new(file_ops::CpCommand),
1109        Box::new(file_ops::MvCommand),
1110        Box::new(file_ops::RmCommand),
1111        Box::new(file_ops::TeeCommand),
1112        Box::new(file_ops::StatCommand),
1113        Box::new(file_ops::ChmodCommand),
1114        Box::new(file_ops::LnCommand),
1115        // Phase 10b: text processing
1116        Box::new(text::GrepCommand),
1117        Box::new(text::SortCommand),
1118        Box::new(text::UniqCommand),
1119        Box::new(text::CutCommand),
1120        Box::new(text::HeadCommand),
1121        Box::new(text::TailCommand),
1122        Box::new(text::WcCommand),
1123        Box::new(text::TrCommand),
1124        Box::new(text::RevCommand),
1125        Box::new(text::FoldCommand),
1126        Box::new(text::NlCommand),
1127        Box::new(text::PrintfCommand),
1128        Box::new(text::PasteCommand),
1129        Box::new(text::OdCommand),
1130        // M2.6: remaining text commands
1131        Box::new(text::TacCommand),
1132        Box::new(text::CommCommand),
1133        Box::new(text::JoinCommand),
1134        Box::new(text::FmtCommand),
1135        Box::new(text::ColumnCommand),
1136        Box::new(text::ExpandCommand),
1137        Box::new(text::UnexpandCommand),
1138        // Phase 10c: navigation
1139        Box::new(navigation::RealpathCommand),
1140        Box::new(navigation::BasenameCommand),
1141        Box::new(navigation::DirnameCommand),
1142        Box::new(navigation::TreeCommand),
1143        // Phase 10d: utilities
1144        Box::new(utils::ExprCommand),
1145        Box::new(utils::DateCommand),
1146        Box::new(utils::SleepCommand),
1147        Box::new(utils::SeqCommand),
1148        Box::new(utils::EnvCommand),
1149        Box::new(utils::PrintenvCommand),
1150        Box::new(utils::WhichCommand),
1151        Box::new(utils::Base64Command),
1152        Box::new(utils::Md5sumCommand),
1153        Box::new(utils::Sha256sumCommand),
1154        Box::new(utils::WhoamiCommand),
1155        Box::new(utils::HostnameCommand),
1156        Box::new(utils::UnameCommand),
1157        Box::new(utils::YesCommand),
1158        // Phase 10e: commands needing exec callback
1159        Box::new(exec_cmds::XargsCommand),
1160        Box::new(exec_cmds::FindCommand),
1161        // M2.5: diff
1162        Box::new(diff_cmd::DiffCommand),
1163        // M2.2: sed
1164        Box::new(sed::SedCommand),
1165        // M2.4: jq
1166        Box::new(jq_cmd::JqCommand),
1167        // M2.3: awk
1168        Box::new(awk::AwkCommand),
1169        // M7.2: core utility commands
1170        Box::new(utils::Sha1sumCommand),
1171        Box::new(utils::TimeoutCommand),
1172        Box::new(utils::FileCommand),
1173        Box::new(utils::BcCommand),
1174        Box::new(utils::ClearCommand),
1175        Box::new(FgrepCommand),
1176        Box::new(EgrepCommand),
1177        // M7.4: binary and file inspection
1178        Box::new(text::StringsCommand),
1179        // M7.5: search commands
1180        Box::new(text::RgCommand),
1181        // M7.2: file operations
1182        Box::new(file_ops::ReadlinkCommand),
1183        Box::new(file_ops::RmdirCommand),
1184        Box::new(file_ops::DuCommand),
1185        Box::new(file_ops::SplitCommand),
1186        // M7.3: compression and archiving
1187        Box::new(compression::GzipCommand),
1188        Box::new(compression::GunzipCommand),
1189        Box::new(compression::ZcatCommand),
1190        Box::new(compression::TarCommand),
1191        // Oils test helpers
1192        Box::new(ArgvPyCommand),
1193        Box::new(PrintenvPyCommand),
1194    ];
1195    for cmd in defaults {
1196        commands.insert(cmd.name().to_string(), cmd);
1197    }
1198    // M3.2: network (feature-gated)
1199    #[cfg(feature = "network")]
1200    {
1201        commands.insert("curl".to_string(), Box::new(net::CurlCommand));
1202    }
1203    commands
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208    use super::*;
1209    use crate::network::NetworkPolicy;
1210    use crate::vfs::InMemoryFs;
1211    use std::sync::Arc;
1212
1213    fn test_ctx() -> (
1214        Arc<InMemoryFs>,
1215        HashMap<String, String>,
1216        ExecutionLimits,
1217        NetworkPolicy,
1218    ) {
1219        (
1220            Arc::new(InMemoryFs::new()),
1221            HashMap::new(),
1222            ExecutionLimits::default(),
1223            NetworkPolicy::default(),
1224        )
1225    }
1226
1227    #[test]
1228    fn echo_no_args() {
1229        let (fs, env, limits, np) = test_ctx();
1230        let ctx = CommandContext {
1231            fs: &*fs,
1232            cwd: "/",
1233            env: &env,
1234            variables: None,
1235            stdin: "",
1236            stdin_bytes: None,
1237            limits: &limits,
1238            network_policy: &np,
1239            exec: None,
1240            shell_opts: None,
1241        };
1242        let result = EchoCommand.execute(&[], &ctx);
1243        assert_eq!(result.stdout, "\n");
1244        assert_eq!(result.exit_code, 0);
1245    }
1246
1247    #[test]
1248    fn echo_simple_text() {
1249        let (fs, env, limits, np) = test_ctx();
1250        let ctx = CommandContext {
1251            fs: &*fs,
1252            cwd: "/",
1253            env: &env,
1254            variables: None,
1255            stdin: "",
1256            stdin_bytes: None,
1257            limits: &limits,
1258            network_policy: &np,
1259            exec: None,
1260            shell_opts: None,
1261        };
1262        let result = EchoCommand.execute(&["hello".into(), "world".into()], &ctx);
1263        assert_eq!(result.stdout, "hello world\n");
1264    }
1265
1266    #[test]
1267    fn echo_flag_n() {
1268        let (fs, env, limits, np) = test_ctx();
1269        let ctx = CommandContext {
1270            fs: &*fs,
1271            cwd: "/",
1272            env: &env,
1273            variables: None,
1274            stdin: "",
1275            stdin_bytes: None,
1276            limits: &limits,
1277            network_policy: &np,
1278            exec: None,
1279            shell_opts: None,
1280        };
1281        let result = EchoCommand.execute(&["-n".into(), "hello".into()], &ctx);
1282        assert_eq!(result.stdout, "hello");
1283    }
1284
1285    #[test]
1286    fn echo_escape_newline() {
1287        let (fs, env, limits, np) = test_ctx();
1288        let ctx = CommandContext {
1289            fs: &*fs,
1290            cwd: "/",
1291            env: &env,
1292            variables: None,
1293            stdin: "",
1294            stdin_bytes: None,
1295            limits: &limits,
1296            network_policy: &np,
1297            exec: None,
1298            shell_opts: None,
1299        };
1300        let result = EchoCommand.execute(&["-e".into(), "hello\\nworld".into()], &ctx);
1301        assert_eq!(result.stdout, "hello\nworld\n");
1302    }
1303
1304    #[test]
1305    fn echo_escape_tab() {
1306        let (fs, env, limits, np) = test_ctx();
1307        let ctx = CommandContext {
1308            fs: &*fs,
1309            cwd: "/",
1310            env: &env,
1311            variables: None,
1312            stdin: "",
1313            stdin_bytes: None,
1314            limits: &limits,
1315            network_policy: &np,
1316            exec: None,
1317            shell_opts: None,
1318        };
1319        let result = EchoCommand.execute(&["-e".into(), "a\\tb".into()], &ctx);
1320        assert_eq!(result.stdout, "a\tb\n");
1321    }
1322
1323    #[test]
1324    fn echo_escape_stop_output() {
1325        let (fs, env, limits, np) = test_ctx();
1326        let ctx = CommandContext {
1327            fs: &*fs,
1328            cwd: "/",
1329            env: &env,
1330            variables: None,
1331            stdin: "",
1332            stdin_bytes: None,
1333            limits: &limits,
1334            network_policy: &np,
1335            exec: None,
1336            shell_opts: None,
1337        };
1338        let result = EchoCommand.execute(&["-e".into(), "hello\\cworld".into()], &ctx);
1339        assert_eq!(result.stdout, "hello");
1340    }
1341
1342    #[test]
1343    fn echo_non_flag_dash_arg() {
1344        let (fs, env, limits, np) = test_ctx();
1345        let ctx = CommandContext {
1346            fs: &*fs,
1347            cwd: "/",
1348            env: &env,
1349            variables: None,
1350            stdin: "",
1351            stdin_bytes: None,
1352            limits: &limits,
1353            network_policy: &np,
1354            exec: None,
1355            shell_opts: None,
1356        };
1357        let result = EchoCommand.execute(&["-z".into(), "hello".into()], &ctx);
1358        assert_eq!(result.stdout, "-z hello\n");
1359    }
1360
1361    #[test]
1362    fn echo_combined_flags() {
1363        let (fs, env, limits, np) = test_ctx();
1364        let ctx = CommandContext {
1365            fs: &*fs,
1366            cwd: "/",
1367            env: &env,
1368            variables: None,
1369            stdin: "",
1370            stdin_bytes: None,
1371            limits: &limits,
1372            network_policy: &np,
1373            exec: None,
1374            shell_opts: None,
1375        };
1376        let result = EchoCommand.execute(&["-ne".into(), "hello\\nworld".into()], &ctx);
1377        assert_eq!(result.stdout, "hello\nworld");
1378    }
1379
1380    #[test]
1381    fn true_succeeds() {
1382        let (fs, env, limits, np) = test_ctx();
1383        let ctx = CommandContext {
1384            fs: &*fs,
1385            cwd: "/",
1386            env: &env,
1387            variables: None,
1388            stdin: "",
1389            stdin_bytes: None,
1390            limits: &limits,
1391            network_policy: &np,
1392            exec: None,
1393            shell_opts: None,
1394        };
1395        assert_eq!(TrueCommand.execute(&[], &ctx).exit_code, 0);
1396    }
1397
1398    #[test]
1399    fn false_fails() {
1400        let (fs, env, limits, np) = test_ctx();
1401        let ctx = CommandContext {
1402            fs: &*fs,
1403            cwd: "/",
1404            env: &env,
1405            variables: None,
1406            stdin: "",
1407            stdin_bytes: None,
1408            limits: &limits,
1409            network_policy: &np,
1410            exec: None,
1411            shell_opts: None,
1412        };
1413        assert_eq!(FalseCommand.execute(&[], &ctx).exit_code, 1);
1414    }
1415}