Skip to main content

bashkit/builtins/
mod.rs

1//! Built-in shell commands
2//!
3//! This module provides the [`Builtin`] trait for implementing custom commands
4//! and the [`Context`] struct for execution context.
5//!
6//! # Custom Builtins
7//!
8//! Implement the [`Builtin`] trait to create custom commands:
9//!
10//! ```rust
11//! use bashkit::{Builtin, BuiltinContext, ExecResult, async_trait};
12//!
13//! struct MyCommand;
14//!
15//! #[async_trait]
16//! impl Builtin for MyCommand {
17//!     async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
18//!         Ok(ExecResult::ok("Hello!\n".to_string()))
19//!     }
20//! }
21//! ```
22//!
23//! Register via [`BashBuilder::builtin`](crate::BashBuilder::builtin).
24
25mod alias;
26mod archive;
27pub(crate) mod arg_parser;
28mod assert;
29mod awk;
30mod base64;
31mod bc;
32mod caller;
33mod cat;
34mod checksum;
35mod clap_env;
36mod clear;
37mod column;
38mod comm;
39mod compgen;
40mod csv;
41mod curl;
42mod cuttr;
43mod date;
44mod diff;
45mod dirstack;
46mod disk;
47mod dotenv;
48mod echo;
49mod environ;
50mod envsubst;
51mod expand;
52mod export;
53mod expr;
54mod fc;
55mod fileops;
56mod flow;
57mod fold;
58mod generated;
59mod glob_cmd;
60mod grep;
61mod headtail;
62mod help;
63mod hextools;
64mod http;
65mod iconv;
66mod inspect;
67mod introspect;
68mod join;
69#[cfg(feature = "jq")]
70mod jq;
71mod json;
72mod log;
73mod ls;
74mod mapfile;
75mod mkfifo;
76mod navigation;
77mod nl;
78mod numfmt;
79mod parallel;
80mod paste;
81mod patch;
82mod path;
83mod pipeline;
84mod printf;
85mod read;
86mod retry;
87mod rg;
88pub(crate) mod search_common;
89mod sed;
90mod semver;
91mod seq;
92mod shuf;
93mod sleep;
94mod sortuniq;
95mod source;
96mod split;
97mod strings;
98mod system;
99mod template;
100mod test;
101mod textrev;
102pub(crate) mod timeout;
103mod tomlq;
104mod trap;
105mod tree;
106mod truncate;
107mod vars;
108mod verify;
109mod wait;
110mod wc;
111mod yaml;
112mod yes;
113mod zip_cmd;
114
115mod helpers;
116pub(crate) use helpers::BuiltinHelper;
117
118pub(crate) mod limits;
119pub(crate) use limits::MAX_FORMAT_WIDTH;
120
121pub(crate) mod git;
122
123pub(crate) mod ssh;
124
125#[cfg(feature = "python")]
126mod python;
127
128#[cfg(feature = "typescript")]
129mod typescript;
130
131#[cfg(feature = "sqlite")]
132mod sqlite;
133
134pub use alias::{Alias, Unalias};
135pub use archive::{Gunzip, Gzip, Tar};
136pub use assert::Assert;
137pub use awk::Awk;
138pub use base64::Base64;
139pub use bc::Bc;
140pub use caller::Caller;
141pub use cat::Cat;
142pub use checksum::{Md5sum, Sha1sum, Sha256sum};
143pub use clear::Clear;
144pub use column::Column;
145pub use comm::Comm;
146pub use compgen::Compgen;
147pub use csv::Csv;
148pub use curl::{Curl, Wget};
149pub use cuttr::{Cut, Tr};
150pub use date::Date;
151pub use diff::Diff;
152pub use dirstack::{Dirs, Popd, Pushd};
153pub use disk::{Df, Du};
154pub use dotenv::Dotenv;
155pub use echo::Echo;
156pub use environ::{Env, History, Printenv};
157pub use envsubst::Envsubst;
158pub use expand::{Expand, Unexpand};
159pub use export::Export;
160pub use expr::Expr;
161pub use fc::Fc;
162pub use fileops::{Chmod, Chown, Cp, Kill, Ln, Mkdir, Mktemp, Mv, Rm, Touch};
163pub use flow::{Break, Colon, Continue, Exit, False, Return, True};
164pub use fold::Fold;
165pub use glob_cmd::GlobCmd;
166pub use grep::Grep;
167pub use headtail::{Head, Tail};
168pub use help::Help;
169pub use hextools::{Hexdump, Od, Xxd};
170pub use http::Http;
171pub use iconv::Iconv;
172pub use inspect::{File, Less, Stat};
173pub use introspect::{Hash, Type, Which};
174pub use join::Join;
175#[cfg(feature = "jq")]
176pub use jq::Jq;
177pub use json::Json;
178pub use log::Log;
179pub(crate) use ls::glob_match;
180pub use ls::{Find, Ls, Rmdir};
181pub use mapfile::Mapfile;
182pub use mkfifo::Mkfifo;
183pub use navigation::{Cd, Pwd};
184pub use nl::Nl;
185pub use numfmt::Numfmt;
186pub use parallel::Parallel;
187pub use paste::Paste;
188pub use patch::Patch;
189pub use path::{Basename, Dirname, Readlink, Realpath};
190pub use pipeline::{Tee, Watch, Xargs};
191pub use printf::Printf;
192pub use read::Read;
193pub use retry::Retry;
194pub use rg::Rg;
195pub use sed::Sed;
196pub use semver::Semver;
197pub use seq::Seq;
198pub use shuf::Shuf;
199
200pub use sleep::Sleep;
201pub use sortuniq::{Sort, Uniq};
202pub use source::Source;
203pub use split::Split;
204pub use strings::Strings;
205pub use system::{DEFAULT_HOSTNAME, DEFAULT_USERNAME, Hostname, Id, Uname, Whoami};
206pub use template::Template;
207pub use test::{Bracket, Test};
208pub use textrev::{Rev, Tac};
209pub use timeout::Timeout;
210pub use tomlq::Tomlq;
211pub use trap::Trap;
212pub use tree::Tree;
213pub use truncate::Truncate;
214pub use vars::{Eval, Local, Readonly, Set, Shift, Shopt, Times, Unset};
215pub use verify::Verify;
216pub use wait::Wait;
217pub use wc::Wc;
218pub use yaml::Yaml;
219pub use yes::Yes;
220pub use zip_cmd::{Unzip, Zip};
221
222#[cfg(feature = "git")]
223pub use git::Git;
224
225#[cfg(feature = "ssh")]
226pub use ssh::{Scp, Sftp, Ssh};
227
228#[cfg(feature = "python")]
229pub(crate) use python::PythonInprocessOptIn;
230#[cfg(feature = "python")]
231pub use python::{Python, PythonExternalFnHandler, PythonExternalFns, PythonLimits};
232
233#[cfg(feature = "typescript")]
234pub use typescript::{
235    TypeScriptConfig, TypeScriptExtension, TypeScriptExternalFnHandler, TypeScriptExternalFns,
236    TypeScriptLimits,
237};
238
239#[cfg(feature = "sqlite")]
240pub(crate) use sqlite::SqliteInprocessOptIn;
241#[cfg(feature = "sqlite")]
242pub use sqlite::{Sqlite, SqliteBackend, SqliteLimits};
243
244use async_trait::async_trait;
245use clap::{CommandFactory, FromArgMatches};
246use std::any::{Any, TypeId};
247use std::collections::HashMap;
248use std::path::{Path, PathBuf};
249use std::sync::Arc;
250
251use crate::error::Result;
252use crate::fs::FileSystem;
253use crate::interpreter::ExecResult;
254
255pub(crate) async fn read_text_file(
256    fs: &dyn FileSystem,
257    path: &Path,
258    cmd_name: &str,
259) -> std::result::Result<String, ExecResult> {
260    let content = fs
261        .read_file(path)
262        .await
263        .map_err(|e| ExecResult::err(format!("{cmd_name}: {}: {e}\n", path.display()), 1))?;
264
265    // Binary device files (/dev/urandom, /dev/random): preserve raw bytes as
266    // Latin-1 (ISO 8859-1) so each byte 0x00-0xFF maps 1:1 to a char.
267    // This lets `tr -dc 'a-z0-9' < /dev/urandom | head -c N` work correctly.
268    if path == Path::new("/dev/urandom") || path == Path::new("/dev/random") {
269        return Ok(content.iter().map(|&b| b as char).collect());
270    }
271
272    Ok(String::from_utf8_lossy(&content).into_owned())
273}
274
275/// Check args for `--help` and optionally `--version`.
276///
277/// Returns `Some(ExecResult)` when the flag is found, `None` otherwise.
278/// Call at the top of `execute()` to add standard help/version support.
279///
280/// Only matches long flags (`--help`, `--version`) because short flags
281/// `-h` and `-V` have different meanings in many tools (e.g. `sort -V`
282/// for version sort, `ls -h` for human-readable, `grep -h` to suppress
283/// filenames).  Tools that want `-h`/`-V` as aliases should handle them
284/// in their own `execute()` method.
285pub(crate) fn check_help_version(
286    args: &[String],
287    help_text: &str,
288    version: Option<&str>,
289) -> Option<ExecResult> {
290    for arg in args {
291        match arg.as_str() {
292            "--help" => return Some(ExecResult::ok(help_text.to_string())),
293            "--version" => {
294                if let Some(ver) = version {
295                    return Some(ExecResult::ok(format!("{ver}\n")));
296                }
297            }
298            // Stop checking after first non-flag argument
299            s if !s.starts_with('-') => break,
300            _ => {}
301        }
302    }
303    None
304}
305
306// Re-export ShellRef for internal builtins
307pub(crate) use crate::interpreter::ShellRef;
308
309// Re-export for use by builtins
310pub use crate::interpreter::BuiltinSideEffect;
311
312/// A bundle of shell capabilities registered together.
313///
314/// Extensions are intended for embedders that want to contribute a family of
315/// builtins as one unit. Builders expand extensions into normal builtin
316/// registrations so command dispatch remains unchanged.
317pub trait Extension: Send + Sync {
318    /// Return builtin commands contributed by this extension.
319    ///
320    /// Later registrations with the same name replace earlier registrations,
321    /// matching [`BashBuilder::builtin`](crate::BashBuilder::builtin).
322    fn builtins(&self) -> Vec<(String, Box<dyn Builtin>)>;
323}
324
325/// Host-owned, mutable registry of builtin commands.
326///
327/// Unlike [`BashBuilder::builtin`](crate::BashBuilder::builtin), entries can be
328/// inserted and removed at any point during the lifetime of the `Bash`
329/// instance — the interpreter consults the registry at command-dispatch time.
330///
331/// Cloning the registry produces a new handle that shares the same underlying
332/// storage; mutations made via any handle are visible to all others. This
333/// lets embedders (FFI bindings, REPLs, plugin systems) hand a handle to the
334/// builder and retain another for runtime registration.
335///
336/// In the command-resolution order, host-registered builtins are checked
337/// after shell functions and POSIX special builtins, but before baked-in
338/// builtins — so embedders can override baked-in commands.
339#[derive(Clone, Default)]
340pub struct BuiltinRegistry {
341    inner: Arc<std::sync::RwLock<HashMap<String, Arc<dyn Builtin>>>>,
342}
343
344impl BuiltinRegistry {
345    /// Create an empty registry.
346    pub fn new() -> Self {
347        Self::default()
348    }
349
350    /// Register or replace a builtin under `name`.
351    pub fn insert(&self, name: impl Into<String>, builtin: Arc<dyn Builtin>) {
352        if let Ok(mut guard) = self.inner.write() {
353            guard.insert(name.into(), builtin);
354        }
355    }
356
357    /// Remove the entry for `name`, returning the previously registered
358    /// builtin if any.
359    pub fn remove(&self, name: &str) -> Option<Arc<dyn Builtin>> {
360        self.inner.write().ok().and_then(|mut g| g.remove(name))
361    }
362
363    /// Look up the builtin registered under `name`, returning a cloned handle.
364    pub fn lookup(&self, name: &str) -> Option<Arc<dyn Builtin>> {
365        self.inner.read().ok().and_then(|g| g.get(name).cloned())
366    }
367
368    /// Return the set of currently registered builtin names.
369    pub fn names(&self) -> Vec<String> {
370        self.inner
371            .read()
372            .map(|g| g.keys().cloned().collect())
373            .unwrap_or_default()
374    }
375
376    /// True if no builtins are registered.
377    pub fn is_empty(&self) -> bool {
378        self.inner.read().map(|g| g.is_empty()).unwrap_or(true)
379    }
380}
381
382/// Typed, per-execution data exposed to builtin implementations.
383///
384/// This is intentionally separate from shell state: extensions live for one
385/// `Bash::exec*()` call, while the shell/interpreter may persist across many
386/// executions.
387#[derive(Default)]
388pub struct ExecutionExtensions {
389    values: HashMap<TypeId, Box<dyn Any + Send + Sync>>,
390}
391
392impl ExecutionExtensions {
393    /// Create an empty execution extension bag.
394    pub fn new() -> Self {
395        Self::default()
396    }
397
398    /// Insert a typed value, replacing any previous value of the same type.
399    pub fn insert<T>(&mut self, value: T) -> Option<T>
400    where
401        T: Send + Sync + 'static,
402    {
403        self.values
404            .insert(TypeId::of::<T>(), Box::new(value))
405            .and_then(|prev| prev.downcast::<T>().ok().map(|prev| *prev))
406    }
407
408    /// Builder-style insert.
409    pub fn with<T>(mut self, value: T) -> Self
410    where
411        T: Send + Sync + 'static,
412    {
413        let _ = self.insert(value);
414        self
415    }
416
417    /// Look up a typed value by exact type.
418    pub fn get<T>(&self) -> Option<&T>
419    where
420        T: Send + Sync + 'static,
421    {
422        self.values
423            .get(&TypeId::of::<T>())
424            .and_then(|value| value.downcast_ref::<T>())
425    }
426
427    /// Return whether the bag is empty.
428    pub fn is_empty(&self) -> bool {
429        self.values.is_empty()
430    }
431}
432
433/// A sub-command that a builtin wants the interpreter to execute.
434///
435/// Builtins like `timeout`, `xargs`, and `find -exec` need to execute
436/// other commands. They return an [`ExecutionPlan`] describing what to
437/// run, and the interpreter handles actual execution.
438#[derive(Debug, Clone)]
439pub struct SubCommand {
440    /// Command name (e.g. "echo", "rm").
441    pub name: String,
442    /// Command arguments.
443    pub args: Vec<String>,
444    /// Optional stdin to pipe into the command.
445    pub stdin: Option<String>,
446}
447
448/// Execution plan returned by builtins that need to run sub-commands.
449///
450/// Instead of executing commands directly (which would require interpreter
451/// access), builtins return a plan that the interpreter fulfills.
452#[derive(Debug)]
453pub enum ExecutionPlan {
454    /// Run a single command with a timeout.
455    Timeout {
456        /// Maximum duration before killing the command.
457        duration: std::time::Duration,
458        /// Whether to preserve the command's exit status on timeout.
459        preserve_status: bool,
460        /// The command to execute.
461        command: SubCommand,
462    },
463    /// Run a sequence of commands, collecting their output.
464    Batch {
465        /// Commands to execute in order.
466        commands: Vec<SubCommand>,
467    },
468    /// Run a sequence of commands, then merge builtin-generated stderr/exit semantics.
469    BatchWithStatus {
470        /// Commands to execute in order.
471        commands: Vec<SubCommand>,
472        /// Builtin-generated stderr to prepend to command stderr.
473        stderr_prefix: String,
474        /// Force non-zero exit (1) when command sequence would otherwise succeed.
475        force_error_exit: bool,
476    },
477}
478
479/// Resolve a path relative to the current working directory.
480///
481/// If the path is absolute, returns it unchanged.
482/// If relative, joins it with the cwd.
483///
484/// # Example
485///
486/// ```ignore
487/// let abs = resolve_path(Path::new("/home"), "/etc/passwd");
488/// assert_eq!(abs, PathBuf::from("/etc/passwd"));
489///
490/// let rel = resolve_path(Path::new("/home"), "file.txt");
491/// assert_eq!(rel, PathBuf::from("/home/file.txt"));
492///
493/// // Paths are normalized (. and .. resolved)
494/// let dot = resolve_path(Path::new("/"), ".");
495/// assert_eq!(dot, PathBuf::from("/"));
496/// ```
497pub fn resolve_path(cwd: &Path, path_str: &str) -> PathBuf {
498    let path = Path::new(path_str);
499    let joined = if path.is_absolute() {
500        path.to_path_buf()
501    } else {
502        cwd.join(path)
503    };
504    // Normalize the path to handle . and .. components
505    normalize_path(&joined)
506}
507
508// Re-export shared normalize_path for use by builtins
509use crate::fs::normalize_path;
510
511/// Execution context for builtin commands.
512///
513/// Provides access to the shell execution environment including arguments,
514/// variables, filesystem, and pipeline input.
515///
516/// # Example
517///
518/// ```rust
519/// use bashkit::{Builtin, BuiltinContext, ExecResult, async_trait};
520///
521/// struct Echo;
522///
523/// #[async_trait]
524/// impl Builtin for Echo {
525///     async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
526///         // Access command arguments
527///         let output = ctx.args.join(" ");
528///
529///         // Access environment variables
530///         let _home = ctx.env.get("HOME");
531///
532///         // Access pipeline input
533///         if let Some(stdin) = ctx.stdin {
534///             return Ok(ExecResult::ok(stdin.to_string()));
535///         }
536///
537///         Ok(ExecResult::ok(format!("{}\n", output)))
538///     }
539/// }
540/// ```
541pub struct Context<'a> {
542    /// Command arguments (not including the command name).
543    ///
544    /// For `mycommand arg1 arg2`, this contains `["arg1", "arg2"]`.
545    pub args: &'a [String],
546
547    /// Environment variables.
548    ///
549    /// Read-only access to variables set via [`BashBuilder::env`](crate::BashBuilder::env)
550    /// or the `export` builtin.
551    pub env: &'a HashMap<String, String>,
552
553    /// Shell variables (mutable).
554    ///
555    /// Allows builtins to set or modify shell variables.
556    #[allow(dead_code)] // Will be used by set, export, declare builtins
557    pub variables: &'a mut HashMap<String, String>,
558
559    /// Current working directory (mutable).
560    ///
561    /// Used by `cd` and path resolution.
562    pub cwd: &'a mut PathBuf,
563
564    /// Virtual filesystem.
565    ///
566    /// Provides async file operations (read, write, mkdir, etc.).
567    pub fs: Arc<dyn FileSystem>,
568
569    /// Standard input from pipeline.
570    ///
571    /// Contains output from the previous command in a pipeline.
572    /// For `echo hello | mycommand`, stdin will be `Some("hello\n")`.
573    pub stdin: Option<&'a str>,
574
575    /// HTTP client for network operations (curl, wget).
576    ///
577    /// Only available when the `network` feature is enabled and
578    /// a [`NetworkAllowlist`](crate::NetworkAllowlist) is configured via
579    /// [`BashBuilder::network`](crate::BashBuilder::network).
580    #[cfg(feature = "http_client")]
581    pub http_client: Option<&'a crate::network::HttpClient>,
582
583    /// Git client for git operations.
584    ///
585    /// Only available when the `git` feature is enabled and
586    /// a [`GitConfig`](crate::GitConfig) is configured via
587    /// [`BashBuilder::git`](crate::BashBuilder::git).
588    #[cfg(feature = "git")]
589    pub git_client: Option<&'a crate::builtins::git::GitClient>,
590
591    /// SSH client for ssh/scp/sftp operations.
592    ///
593    /// Only available when the `ssh` feature is enabled and
594    /// an [`SshConfig`](crate::SshConfig) is configured via
595    /// [`BashBuilder::ssh`](crate::BashBuilder::ssh).
596    #[cfg(feature = "ssh")]
597    pub ssh_client: Option<&'a crate::builtins::ssh::SshClient>,
598
599    /// Direct access to interpreter shell state.
600    ///
601    /// Provides internal builtins with:
602    /// - **Mutable access** to aliases and traps (simple HashMap state)
603    /// - **Read-only access** to functions, builtins, call stack, history, jobs
604    ///
605    /// `None` for custom/external builtins; `Some(...)` for internal builtins
606    /// that need interpreter state (e.g. `type`, `alias`, `trap`).
607    ///
608    /// Design: aliases/traps are directly mutable because they're simple HashMaps
609    /// with no invariants. Arrays use [`BuiltinSideEffect`] because they need
610    /// budget checking. History uses side effects for VFS persistence.
611    pub(crate) shell: Option<ShellRef<'a>>,
612}
613
614impl<'a> Context<'a> {
615    /// Look up a typed per-execution extension, if present.
616    pub fn execution_extension<T>(&self) -> Option<&T>
617    where
618        T: Send + Sync + 'static,
619    {
620        self.shell
621            .as_ref()
622            .and_then(|shell| shell.execution_extensions.get::<T>())
623    }
624
625    /// Create a new Context for testing purposes.
626    ///
627    /// This helper handles the conditional `http_client` field automatically.
628    #[cfg(test)]
629    pub fn new_for_test(
630        args: &'a [String],
631        env: &'a std::collections::HashMap<String, String>,
632        variables: &'a mut std::collections::HashMap<String, String>,
633        cwd: &'a mut std::path::PathBuf,
634        fs: std::sync::Arc<dyn crate::fs::FileSystem>,
635        stdin: Option<&'a str>,
636    ) -> Self {
637        Self {
638            args,
639            env,
640            variables,
641            cwd,
642            fs,
643            stdin,
644            #[cfg(feature = "http_client")]
645            http_client: None,
646            #[cfg(feature = "git")]
647            git_client: None,
648            #[cfg(feature = "ssh")]
649            ssh_client: None,
650            shell: None,
651        }
652    }
653}
654
655/// Trait for implementing builtin commands.
656///
657/// All custom builtins must implement this trait. The trait requires `Send + Sync`
658/// for thread safety in async contexts.
659///
660/// # Example
661///
662/// ```rust
663/// use bashkit::{Bash, Builtin, BuiltinContext, ExecResult, async_trait};
664///
665/// struct Greet {
666///     default_name: String,
667/// }
668///
669/// #[async_trait]
670/// impl Builtin for Greet {
671///     async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
672///         let name = ctx.args.first()
673///             .map(|s| s.as_str())
674///             .unwrap_or(&self.default_name);
675///         Ok(ExecResult::ok(format!("Hello, {}!\n", name)))
676///     }
677/// }
678///
679/// // Register the builtin
680/// let bash = Bash::builder()
681///     .builtin("greet", Box::new(Greet { default_name: "World".into() }))
682///     .build();
683/// ```
684///
685/// # LLM Hints
686///
687/// Builtins can provide short hints for LLM system prompts via [`llm_hint`](Builtin::llm_hint).
688/// These appear in the tool's `help()` and `system_prompt()` output so LLMs know
689/// about capabilities and limitations.
690///
691/// # Return Values
692///
693/// Return [`ExecResult::ok`](crate::ExecResult::ok) for success with output,
694/// or [`ExecResult::err`](crate::ExecResult::err) for errors with exit code.
695#[async_trait]
696pub trait Builtin: Send + Sync {
697    /// Execute the builtin command.
698    ///
699    /// # Arguments
700    ///
701    /// * `ctx` - The execution context containing arguments, environment, and filesystem
702    ///
703    /// # Returns
704    ///
705    /// * `Ok(ExecResult)` - Execution result with stdout, stderr, and exit code
706    /// * `Err(Error)` - Fatal error that should abort execution
707    async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult>;
708
709    /// Return an execution plan for sub-command execution.
710    ///
711    /// Builtins that need to execute other commands (e.g. `timeout`, `xargs`,
712    /// `find -exec`) override this to return an `ExecutionPlan`. The interpreter
713    /// fulfills the plan by executing the sub-commands and returning results.
714    ///
715    /// When this returns `Some(plan)`, the interpreter ignores the `execute()`
716    /// result and instead runs the plan. When `None`, normal `execute()` is used.
717    ///
718    /// The default implementation returns `Ok(None)`.
719    async fn execution_plan(&self, _ctx: &Context<'_>) -> Result<Option<ExecutionPlan>> {
720        Ok(None)
721    }
722
723    /// Optional short hint for LLM system prompts.
724    ///
725    /// Return a concise one-line description of capabilities and limitations.
726    /// These hints are included in `help()` and `system_prompt()` output
727    /// when the builtin is registered.
728    ///
729    /// # Example
730    ///
731    /// ```rust,ignore
732    /// fn llm_hint(&self) -> Option<&'static str> {
733    ///     Some("mycommand: Processes data files. Max 10MB input. No network access.")
734    /// }
735    /// ```
736    fn llm_hint(&self) -> Option<&'static str> {
737        None
738    }
739
740    /// Clear hidden per-instance state that must not survive snapshot restore.
741    ///
742    /// Most builtins are stateless and keep the default no-op. Builtins with
743    /// session caches must override this so [`Bash::restore_snapshot`](crate::Bash::restore_snapshot)
744    /// restores the full observable boundary, not just shell variables and VFS bytes.
745    fn reset_session_state(&self) {}
746}
747
748/// Trait for custom builtins that parse arguments with [`clap`].
749///
750/// Implement this trait when a builtin has enough flags/options that deriving a
751/// `clap::Parser` struct is clearer than manually inspecting [`Context::args`].
752///
753/// # Example
754///
755/// ```rust
756/// use bashkit::{Bash, BashkitContext, ClapBuiltin, async_trait};
757/// use bashkit::clap::Parser;
758///
759/// #[derive(Parser)]
760/// #[command(name = "greet")]
761/// struct GreetArgs {
762///     #[arg(short, long, default_value = "World")]
763///     name: String,
764/// }
765///
766/// struct Greet;
767///
768/// #[async_trait]
769/// impl ClapBuiltin for Greet {
770///     type Args = GreetArgs;
771///
772///     async fn execute_clap(
773///         &self,
774///         args: Self::Args,
775///         ctx: &mut BashkitContext<'_>,
776///     ) -> bashkit::Result<()> {
777///         ctx.write_stdout(format!("Hello, {}!\n", args.name));
778///         Ok(())
779///     }
780/// }
781///
782/// # #[tokio::main]
783/// # async fn main() -> bashkit::Result<()> {
784/// let mut bash = Bash::builder()
785///     .builtin("greet", Box::new(Greet))
786///     .build();
787/// let result = bash.exec("greet --name Alice").await?;
788/// assert_eq!(result.stdout, "Hello, Alice!\n");
789/// # Ok(())
790/// # }
791/// ```
792#[async_trait]
793pub trait ClapBuiltin: Send + Sync {
794    /// Clap parser type for this builtin's arguments.
795    type Args: clap::Parser + Send + 'static;
796
797    /// Execute the builtin with already-parsed clap arguments.
798    async fn execute_clap(&self, args: Self::Args, ctx: &mut BashkitContext<'_>) -> Result<()>;
799
800    /// Optional short hint for LLM system prompts.
801    fn llm_hint(&self) -> Option<&'static str> {
802        None
803    }
804
805    /// Clear hidden per-instance state that must not survive snapshot restore.
806    ///
807    /// Most builtins are stateless and keep the default no-op. Builtins with
808    /// session caches must override this so [`Bash::restore_snapshot`](crate::Bash::restore_snapshot)
809    /// restores the full observable boundary, not just shell variables and VFS bytes.
810    fn reset_session_state(&self) {}
811}
812
813/// Mutable execution context for [`ClapBuiltin`] implementations.
814///
815/// This context keeps clap-backed builtins close to normal CLI code: handlers
816/// write to stdout/stderr, set an exit code when needed, and return
817/// `bashkit::Result<()>` for fatal host-side errors.
818pub struct BashkitContext<'a> {
819    inner: Context<'a>,
820
821    /// Captured stdout for this builtin invocation.
822    pub stdout: String,
823
824    /// Captured stderr for this builtin invocation.
825    pub stderr: String,
826
827    /// Exit code for this builtin invocation.
828    pub exit_code: i32,
829}
830
831impl<'a> BashkitContext<'a> {
832    fn new(inner: Context<'a>) -> Self {
833        Self {
834            inner,
835            stdout: String::new(),
836            stderr: String::new(),
837            exit_code: 0,
838        }
839    }
840
841    fn into_exec_result(self) -> ExecResult {
842        ExecResult {
843            stdout: self.stdout,
844            stderr: self.stderr,
845            exit_code: self.exit_code,
846            ..Default::default()
847        }
848    }
849
850    /// Original shell words passed to the builtin, after shell expansion.
851    pub fn raw_args(&self) -> &[String] {
852        self.inner.args
853    }
854
855    /// Environment variables visible to this builtin.
856    pub fn env(&self) -> &HashMap<String, String> {
857        self.inner.env
858    }
859
860    /// Mutable shell variables.
861    pub fn variables(&mut self) -> &mut HashMap<String, String> {
862        self.inner.variables
863    }
864
865    /// Current working directory.
866    pub fn cwd(&self) -> &Path {
867        self.inner.cwd
868    }
869
870    /// Mutable current working directory.
871    pub fn cwd_mut(&mut self) -> &mut PathBuf {
872        self.inner.cwd
873    }
874
875    /// Virtual filesystem for this shell.
876    pub fn fs(&self) -> Arc<dyn FileSystem> {
877        Arc::clone(&self.inner.fs)
878    }
879
880    /// Pipeline stdin, if the builtin is invoked after a pipe.
881    pub fn stdin(&self) -> Option<&str> {
882        self.inner.stdin
883    }
884
885    /// Look up a typed per-execution extension, if present.
886    pub fn execution_extension<T>(&self) -> Option<&T>
887    where
888        T: Send + Sync + 'static,
889    {
890        self.inner.execution_extension::<T>()
891    }
892
893    /// Append text to stdout.
894    pub fn write_stdout(&mut self, output: impl AsRef<str>) {
895        self.stdout.push_str(output.as_ref());
896    }
897
898    /// Append text to stderr.
899    pub fn write_stderr(&mut self, output: impl AsRef<str>) {
900        self.stderr.push_str(output.as_ref());
901    }
902
903    /// Set the command exit code.
904    pub fn set_exit_code(&mut self, exit_code: i32) {
905        self.exit_code = exit_code;
906    }
907
908    /// Append stderr text and set a failing exit code.
909    pub fn fail(&mut self, stderr: impl AsRef<str>, exit_code: i32) {
910        self.write_stderr(stderr);
911        self.set_exit_code(exit_code);
912    }
913}
914
915#[async_trait]
916impl<T> Builtin for T
917where
918    T: ClapBuiltin,
919{
920    async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
921        let mut command = <T::Args as CommandFactory>::command().color(clap::ColorChoice::Never);
922        let command_name = command.get_name().to_string();
923        let argv = std::iter::once(command_name).chain(ctx.args.iter().cloned());
924
925        let mut matches = match command.try_get_matches_from_mut(argv) {
926            Ok(matches) => matches,
927            Err(error) => return Ok(clap_error_to_exec_result(error)),
928        };
929        let args = match <T::Args as FromArgMatches>::from_arg_matches_mut(&mut matches) {
930            Ok(args) => args,
931            Err(error) => return Ok(clap_error_to_exec_result(error)),
932        };
933
934        let mut ctx = BashkitContext::new(ctx);
935        self.execute_clap(args, &mut ctx).await?;
936        Ok(ctx.into_exec_result())
937    }
938
939    fn llm_hint(&self) -> Option<&'static str> {
940        ClapBuiltin::llm_hint(self)
941    }
942
943    fn reset_session_state(&self) {
944        ClapBuiltin::reset_session_state(self);
945    }
946}
947
948fn clap_error_to_exec_result(error: clap::Error) -> ExecResult {
949    let text = error.to_string();
950    if matches!(
951        error.kind(),
952        clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion
953    ) {
954        return ExecResult::ok(text);
955    }
956
957    ExecResult::err(cap_diagnostic(text, 1024), error.exit_code())
958}
959
960fn cap_diagnostic(mut text: String, max_bytes: usize) -> String {
961    if text.len() <= max_bytes {
962        return text;
963    }
964
965    let cut = text
966        .char_indices()
967        .map(|(idx, _)| idx)
968        .take_while(|idx| *idx <= max_bytes)
969        .last()
970        .unwrap_or(0);
971    text.truncate(cut);
972    text
973}
974
975#[async_trait]
976impl Builtin for std::sync::Arc<dyn Builtin> {
977    async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
978        (**self).execute(ctx).await
979    }
980
981    async fn execution_plan(&self, ctx: &Context<'_>) -> Result<Option<ExecutionPlan>> {
982        (**self).execution_plan(ctx).await
983    }
984
985    fn llm_hint(&self) -> Option<&'static str> {
986        (**self).llm_hint()
987    }
988}
989
990/// Internal alias for `crate::testing` so per-tool `#[cfg(test)]`
991/// modules can keep their existing `crate::builtins::debug_leak_check::*`
992/// imports. The source of truth is `crate::testing` (which is also
993/// reachable from integration tests and cargo-fuzz targets).
994#[cfg(test)]
995pub(crate) use crate::testing as debug_leak_check;
996
997#[cfg(test)]
998mod tests {
999    use super::*;
1000    use crate::fs::{FileSystem, InMemoryFs};
1001
1002    #[test]
1003    fn test_resolve_path_absolute() {
1004        let cwd = PathBuf::from("/home/user");
1005        let result = resolve_path(&cwd, "/tmp/file.txt");
1006        assert_eq!(result, PathBuf::from("/tmp/file.txt"));
1007    }
1008
1009    #[test]
1010    fn test_resolve_path_relative() {
1011        let cwd = PathBuf::from("/home/user");
1012        let result = resolve_path(&cwd, "downloads/file.txt");
1013        assert_eq!(result, PathBuf::from("/home/user/downloads/file.txt"));
1014    }
1015
1016    #[test]
1017    fn test_resolve_path_dot_from_root() {
1018        // "." from root should normalize to "/"
1019        let cwd = PathBuf::from("/");
1020        let result = resolve_path(&cwd, ".");
1021        assert_eq!(result, PathBuf::from("/"));
1022    }
1023
1024    #[test]
1025    fn test_resolve_path_dot_from_normal_dir() {
1026        // "." should be stripped, returning the cwd itself
1027        let cwd = PathBuf::from("/home/user");
1028        let result = resolve_path(&cwd, ".");
1029        assert_eq!(result, PathBuf::from("/home/user"));
1030    }
1031
1032    #[test]
1033    fn test_resolve_path_dotdot() {
1034        // ".." should go up one directory
1035        let cwd = PathBuf::from("/home/user");
1036        let result = resolve_path(&cwd, "..");
1037        assert_eq!(result, PathBuf::from("/home"));
1038    }
1039
1040    #[test]
1041    fn test_resolve_path_dotdot_from_root() {
1042        // ".." from root stays at root
1043        let cwd = PathBuf::from("/");
1044        let result = resolve_path(&cwd, "..");
1045        assert_eq!(result, PathBuf::from("/"));
1046    }
1047
1048    #[test]
1049    fn test_resolve_path_complex() {
1050        // Complex path with . and ..
1051        let cwd = PathBuf::from("/home/user");
1052        let result = resolve_path(&cwd, "./downloads/../documents/./file.txt");
1053        assert_eq!(result, PathBuf::from("/home/user/documents/file.txt"));
1054    }
1055
1056    #[tokio::test]
1057    async fn read_text_file_returns_lossy_utf8() {
1058        let fs = InMemoryFs::new();
1059        fs.write_file(Path::new("/tmp/data.bin"), b"hi\xffthere")
1060            .await
1061            .unwrap();
1062
1063        let text = read_text_file(&fs, Path::new("/tmp/data.bin"), "cat")
1064            .await
1065            .unwrap();
1066
1067        assert_eq!(text, "hi\u{fffd}there");
1068    }
1069
1070    #[tokio::test]
1071    async fn read_text_file_formats_missing_file_errors() {
1072        let fs = InMemoryFs::new();
1073        let err = read_text_file(&fs, Path::new("/tmp/missing.txt"), "cat")
1074            .await
1075            .unwrap_err();
1076
1077        assert_eq!(err.exit_code, 1);
1078        assert!(err.stderr.contains("cat: /tmp/missing.txt:"));
1079    }
1080
1081    #[test]
1082    fn check_help_version_returns_help() {
1083        let args = vec!["--help".to_string()];
1084        let r = check_help_version(&args, "usage text\n", Some("v1.0"));
1085        assert!(r.is_some());
1086        assert_eq!(r.unwrap().stdout, "usage text\n");
1087    }
1088
1089    #[test]
1090    fn check_help_version_returns_version() {
1091        let args = vec!["--version".to_string()];
1092        let r = check_help_version(&args, "usage\n", Some("tool 1.0"));
1093        assert!(r.is_some());
1094        assert_eq!(r.unwrap().stdout, "tool 1.0\n");
1095    }
1096
1097    #[test]
1098    fn check_help_version_no_version_configured() {
1099        let args = vec!["--version".to_string()];
1100        let r = check_help_version(&args, "usage\n", None);
1101        assert!(r.is_none());
1102    }
1103
1104    #[test]
1105    fn check_help_version_stops_at_non_flag() {
1106        let args = vec!["file.txt".to_string(), "--help".to_string()];
1107        let r = check_help_version(&args, "usage\n", None);
1108        assert!(r.is_none());
1109    }
1110
1111    #[test]
1112    fn check_help_version_no_match() {
1113        let args = vec!["-c".to_string(), "filter".to_string()];
1114        let r = check_help_version(&args, "usage\n", Some("v1"));
1115        assert!(r.is_none());
1116    }
1117
1118    // -------------------------------------------------------------------------
1119    // TM-INF-022: stderr from builtins must not leak Rust Debug shapes.
1120    //
1121    // Static guard — walks every `crates/bashkit/src/builtins/*.rs` file
1122    // and asserts no Rust Debug format directives appear, modulo
1123    // `// debug-ok: <reason>` per-line opt-outs.
1124    //
1125    // Dynamic counterpart: each tool's own `mod tests` exercises its
1126    // error paths through `super::debug_leak_check::assert_no_leak`.
1127    // -------------------------------------------------------------------------
1128
1129    #[test]
1130    fn no_debug_fmt_in_builtin_source() {
1131        // Match `{:?}`, `{:#?}`, `{name:?}`, `{name:#?}`. // debug-ok: pattern doc
1132        let pat = regex::Regex::new(r"\{[A-Za-z0-9_]*:#?\?\}").unwrap();
1133        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/builtins");
1134        let mut violations = Vec::new();
1135
1136        // Recursive walk so submodule directories like `builtins/jq/` are
1137        // covered too. The TM-INF-022 invariant must hold for every builtin
1138        // source file regardless of layout.
1139        fn walk(dir: &std::path::Path, violations: &mut Vec<String>, pat: &regex::Regex) {
1140            for entry in std::fs::read_dir(dir).expect("read builtins dir") {
1141                let entry = entry.unwrap();
1142                let path = entry.path();
1143                if path.is_dir() {
1144                    walk(&path, violations, pat);
1145                    continue;
1146                }
1147                if path.extension().is_none_or(|e| e != "rs") {
1148                    continue;
1149                }
1150                let src = std::fs::read_to_string(&path).expect("read source");
1151                for (i, line) in src.lines().enumerate() {
1152                    if line.contains("// debug-ok:") {
1153                        continue;
1154                    }
1155                    if line.trim_start().starts_with("#[derive(") {
1156                        continue;
1157                    }
1158                    if pat.is_match(line) {
1159                        // Show parent dir + filename so jq submodules are
1160                        // distinguishable from the top-level files.
1161                        let rel = path
1162                            .strip_prefix(std::path::Path::new(env!("CARGO_MANIFEST_DIR")))
1163                            .unwrap_or(&path);
1164                        violations.push(format!(
1165                            "{}:{}: {}",
1166                            rel.display(),
1167                            i + 1,
1168                            line.trim_end()
1169                        ));
1170                    }
1171                }
1172            }
1173        }
1174        walk(&dir, &mut violations, &pat);
1175
1176        assert!(
1177            violations.is_empty(),
1178            "Rust Debug formatting found in builtin source. This leaks \
1179             internal struct shapes into stderr where LLM agents see them. \
1180             Use Display ({{}}) or a domain-specific formatter. Add \
1181             `// debug-ok: <reason>` to the line for legitimate test \
1182             asserts.\n\nViolations:\n{}",
1183            violations.join("\n")
1184        );
1185    }
1186
1187    /// TM-INF-024: clap `Arg::env(...)` reads defaults from the real
1188    /// process environment. uutils ships `.env("TABSIZE")` /
1189    /// `.env("TIME_STYLE")` on `ls`, but bashkit isolates scripts inside
1190    /// `ctx.env`; if the host parser were allowed to consult `std::env`
1191    /// the sandbox boundary would leak (host can probe presence, host can
1192    /// inject values that propagate into bashkit's option-validation
1193    /// path). Codegen strips `.env(...)` from generated Arg chains and
1194    /// re-emits the metadata as a `<UTIL>_ENV_DEFAULTS` table that the
1195    /// `clap_env::apply_env_defaults` shim feeds from `ctx.env`. This
1196    /// static guard makes sure no future regen (or hand-edit) re-adds
1197    /// a runtime `.env(...)` call. Defence-in-depth: the workspace
1198    /// `clap` dep also drops the `env` cargo feature, so a slipped
1199    /// `.env(...)` won't compile.
1200    #[test]
1201    fn no_clap_env_in_generated_parsers() {
1202        let pat = regex::Regex::new(r"\.env\s*\(").unwrap();
1203        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/builtins/generated");
1204        let mut violations = Vec::new();
1205        for entry in std::fs::read_dir(&dir).expect("read generated dir") {
1206            let entry = entry.unwrap();
1207            let path = entry.path();
1208            if path.extension().and_then(|s| s.to_str()) != Some("rs") {
1209                continue;
1210            }
1211            let src = std::fs::read_to_string(&path).expect("read generated file");
1212            for (i, line) in src.lines().enumerate() {
1213                // Skip doc/line comments — they reference `.env(...)`
1214                // when describing the harvest rule. We only care about
1215                // real call expressions.
1216                if line.trim_start().starts_with("//") {
1217                    continue;
1218                }
1219                if pat.is_match(line) {
1220                    let rel = path
1221                        .strip_prefix(std::path::Path::new(env!("CARGO_MANIFEST_DIR")))
1222                        .unwrap_or(&path);
1223                    violations.push(format!("{}:{}: {}", rel.display(), i + 1, line.trim_end()));
1224                }
1225            }
1226        }
1227        assert!(
1228            violations.is_empty(),
1229            "clap `Arg::env(...)` found in a generated parser. This pulls \
1230             defaults from the host process environment and breaks bashkit's \
1231             sandbox boundary (TM-INF-024). Re-run `just regen-coreutils-args` \
1232             — the codegen harvests these into `<UTIL>_ENV_DEFAULTS` instead — \
1233             or remove the call by hand.\n\n{}",
1234            violations.join("\n")
1235        );
1236    }
1237
1238    /// Every `<util>_args.rs` MUST expose a `<UTIL>_ENV_DEFAULTS` static.
1239    /// The codegen always emits one (possibly empty) so the bashkit-side
1240    /// surface is uniform — every clap-based builtin can wire through
1241    /// `apply_env_defaults` without per-util conditional code. Catches
1242    /// the regression where a regen drops the table because the codegen
1243    /// branch that emits it was removed or skipped.
1244    #[test]
1245    fn every_generated_parser_emits_env_defaults_table() {
1246        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/builtins/generated");
1247        let mut missing = Vec::new();
1248        for entry in std::fs::read_dir(&dir).expect("read generated dir") {
1249            let entry = entry.unwrap();
1250            let path = entry.path();
1251            let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
1252            if !name.ends_with("_args.rs") {
1253                continue;
1254            }
1255            // util name is everything before `_args.rs`
1256            let util = name.trim_end_matches("_args.rs");
1257            let const_name = format!("{}_ENV_DEFAULTS", util.to_uppercase());
1258            let needle = format!("pub static {const_name}");
1259            let src = std::fs::read_to_string(&path).expect("read generated file");
1260            if !src.contains(&needle) {
1261                let rel = path
1262                    .strip_prefix(std::path::Path::new(env!("CARGO_MANIFEST_DIR")))
1263                    .unwrap_or(&path);
1264                missing.push(format!("{}: missing `{needle}: ...`", rel.display()));
1265            }
1266        }
1267        assert!(
1268            missing.is_empty(),
1269            "Generated parser is missing its `<UTIL>_ENV_DEFAULTS` sidecar. \
1270             The codegen always emits this (possibly empty) so the bashkit \
1271             builtin can route argv through `apply_env_defaults` without \
1272             per-util conditionals. Re-run `just regen-coreutils-args` to \
1273             regenerate.\n\n{}",
1274            missing.join("\n")
1275        );
1276    }
1277
1278    /// Pin LS's env-default surface explicitly. uutils' upstream `ls`
1279    /// uses `.env("TABSIZE")` and `.env("TIME_STYLE")` as of the pinned
1280    /// rev — both must appear in `LS_ENV_DEFAULTS`, with matching long
1281    /// flags, or the virtual-env shim silently drops them. Updating
1282    /// uutils may legitimately add or remove rows; bump this list in
1283    /// the same PR as the codegen regen.
1284    #[test]
1285    fn ls_env_defaults_surface_matches_uutils() {
1286        use crate::builtins::generated::ls_args::LS_ENV_DEFAULTS;
1287        let mut got: Vec<(&'static str, &'static str)> = LS_ENV_DEFAULTS
1288            .iter()
1289            .map(|d| (d.env_var, d.long))
1290            .collect();
1291        got.sort();
1292        let mut expected = vec![("TABSIZE", "tabsize"), ("TIME_STYLE", "time-style")];
1293        expected.sort();
1294        assert_eq!(
1295            got, expected,
1296            "LS_ENV_DEFAULTS surface drifted from upstream uutils. Either \
1297             the codegen harvest dropped a row, or uutils added/removed an \
1298             `.env(...)` annotation on `ls` — bump this fixture together \
1299             with the regen."
1300        );
1301    }
1302
1303    /// Every `<util>_args.rs` header MUST reference the same uutils
1304    /// revision as `generated::UUTILS_REVISION`. The drift workflow
1305    /// bumps both atomically; this test catches the case where someone
1306    /// regenerates a single util at a different rev and forgets to
1307    /// update the pin (or vice versa).
1308    #[test]
1309    fn generated_args_headers_match_pinned_uutils_revision() {
1310        let pin = generated::UUTILS_REVISION;
1311        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/builtins/generated");
1312
1313        let mut mismatches = Vec::new();
1314        for entry in std::fs::read_dir(&dir).expect("read generated dir") {
1315            let entry = entry.unwrap();
1316            let path = entry.path();
1317            if path.extension().and_then(|s| s.to_str()) != Some("rs") {
1318                continue;
1319            }
1320            let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
1321            // Only `*_args.rs` carry per-util headers; `mod.rs` holds
1322            // the pin itself.
1323            if !name.ends_with("_args.rs") {
1324                continue;
1325            }
1326            let body = std::fs::read_to_string(&path).expect("read generated file");
1327            let header_rev = body
1328                .lines()
1329                .find_map(|l| {
1330                    l.strip_prefix("// Source: uutils/coreutils@")
1331                        .and_then(|rest| rest.split_whitespace().next())
1332                })
1333                .unwrap_or("");
1334            if header_rev != pin {
1335                mismatches.push(format!(
1336                    "{}: header references uutils@{header_rev}, pin is uutils@{pin}",
1337                    path.display()
1338                ));
1339            }
1340        }
1341        assert!(
1342            mismatches.is_empty(),
1343            "Generated argument files drift from `generated::UUTILS_REVISION` \
1344             (`{pin}`). Regenerate every util at the pinned rev or bump the \
1345             pin to match. The drift workflow does both atomically; manual \
1346             bumps must too.\n\n{}",
1347            mismatches.join("\n")
1348        );
1349    }
1350
1351    /// Coarse sweep: every common flag-accepting builtin called with a
1352    /// bogus flag must produce a clean error. Tools without flag parsing
1353    /// (`true`, `false`, `:`) and tools that take a path/filter as their
1354    /// first arg (`cd`, `source`) are excluded.
1355    #[tokio::test]
1356    async fn every_builtin_handles_bogus_flag_cleanly() {
1357        const TOOLS: &[&str] = &[
1358            "cat",
1359            "ls",
1360            "wc",
1361            "head",
1362            "tail",
1363            "sort",
1364            "uniq",
1365            "cut",
1366            "tr",
1367            "grep",
1368            "sed",
1369            "awk",
1370            "find",
1371            "tree",
1372            "diff",
1373            "comm",
1374            "paste",
1375            "column",
1376            "join",
1377            "split",
1378            "fold",
1379            "expand",
1380            "unexpand",
1381            "nl",
1382            "tac",
1383            "truncate",
1384            "shuf",
1385            "rev",
1386            "strings",
1387            "od",
1388            "xxd",
1389            "hexdump",
1390            "base64",
1391            "md5sum",
1392            "sha1sum",
1393            "sha256sum",
1394            "tar",
1395            "gzip",
1396            "gunzip",
1397            "zip",
1398            "unzip",
1399            "seq",
1400            "expr",
1401            "bc",
1402            "numfmt",
1403            "test",
1404            "printf",
1405            "echo",
1406            "env",
1407            "printenv",
1408            "stat",
1409            "file",
1410            "basename",
1411            "dirname",
1412            "realpath",
1413            "readlink",
1414            "mktemp",
1415            "tee",
1416            "csv",
1417            "json",
1418            "yaml",
1419            "tomlq",
1420            "jq",
1421            "semver",
1422            "envsubst",
1423            "template",
1424            "patch",
1425        ];
1426        for tool in TOOLS {
1427            let r =
1428                super::debug_leak_check::run(&format!("{tool} --xyzzy-not-a-real-flag </dev/null"))
1429                    .await;
1430            super::debug_leak_check::assert_no_leak(&r, &format!("{tool}_bogus_flag"), &[]);
1431        }
1432    }
1433}