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