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 archive;
26mod awk;
27mod cat;
28mod column;
29mod comm;
30mod curl;
31mod cuttr;
32mod date;
33mod diff;
34mod disk;
35mod echo;
36mod environ;
37mod export;
38mod fileops;
39mod flow;
40mod grep;
41mod headtail;
42mod hextools;
43mod inspect;
44mod jq;
45mod ls;
46mod navigation;
47mod nl;
48mod paste;
49mod path;
50mod pipeline;
51mod printf;
52mod read;
53mod sed;
54mod sleep;
55mod sortuniq;
56mod source;
57mod strings;
58mod system;
59mod test;
60mod timeout;
61mod vars;
62mod wait;
63mod wc;
64
65#[cfg(feature = "git")]
66mod git;
67
68#[cfg(feature = "python")]
69mod python;
70
71pub use archive::{Gunzip, Gzip, Tar};
72pub use awk::Awk;
73pub use cat::Cat;
74pub use column::Column;
75pub use comm::Comm;
76pub use curl::{Curl, Wget};
77pub use cuttr::{Cut, Tr};
78pub use date::Date;
79pub use diff::Diff;
80pub use disk::{Df, Du};
81pub use echo::Echo;
82pub use environ::{Env, History, Printenv};
83pub use export::Export;
84pub use fileops::{Chmod, Cp, Mkdir, Mv, Rm, Touch};
85pub use flow::{Break, Colon, Continue, Exit, False, Return, True};
86pub use grep::Grep;
87pub use headtail::{Head, Tail};
88pub use hextools::{Hexdump, Od, Xxd};
89pub use inspect::{File, Less, Stat};
90pub use jq::Jq;
91pub use ls::{Find, Ls, Rmdir};
92pub use navigation::{Cd, Pwd};
93pub use nl::Nl;
94pub use paste::Paste;
95pub use path::{Basename, Dirname};
96pub use pipeline::{Tee, Watch, Xargs};
97pub use printf::Printf;
98pub use read::Read;
99pub use sed::Sed;
100pub use sleep::Sleep;
101pub use sortuniq::{Sort, Uniq};
102pub use source::Source;
103pub use strings::Strings;
104pub use system::{Hostname, Id, Uname, Whoami, DEFAULT_HOSTNAME, DEFAULT_USERNAME};
105pub use test::{Bracket, Test};
106pub use timeout::Timeout;
107pub use vars::{Eval, Local, Readonly, Set, Shift, Times, Unset};
108pub use wait::Wait;
109pub use wc::Wc;
110
111#[cfg(feature = "git")]
112pub use git::Git;
113
114#[cfg(feature = "python")]
115pub use python::{Python, PythonLimits};
116
117use async_trait::async_trait;
118use std::collections::HashMap;
119use std::path::{Path, PathBuf};
120use std::sync::Arc;
121
122use crate::error::Result;
123use crate::fs::FileSystem;
124use crate::interpreter::ExecResult;
125
126/// Resolve a path relative to the current working directory.
127///
128/// If the path is absolute, returns it unchanged.
129/// If relative, joins it with the cwd.
130///
131/// # Example
132///
133/// ```ignore
134/// let abs = resolve_path(Path::new("/home"), "/etc/passwd");
135/// assert_eq!(abs, PathBuf::from("/etc/passwd"));
136///
137/// let rel = resolve_path(Path::new("/home"), "file.txt");
138/// assert_eq!(rel, PathBuf::from("/home/file.txt"));
139///
140/// // Paths are normalized (. and .. resolved)
141/// let dot = resolve_path(Path::new("/"), ".");
142/// assert_eq!(dot, PathBuf::from("/"));
143/// ```
144pub fn resolve_path(cwd: &Path, path_str: &str) -> PathBuf {
145    let path = Path::new(path_str);
146    let joined = if path.is_absolute() {
147        path.to_path_buf()
148    } else {
149        cwd.join(path)
150    };
151    // Normalize the path to handle . and .. components
152    normalize_path(&joined)
153}
154
155/// Normalize a path by resolving `.` and `..` components.
156///
157/// This ensures paths like `/.` become `/` and `/tmp/../home` becomes `/home`.
158/// Used internally to ensure filesystem implementations receive clean paths.
159fn normalize_path(path: &Path) -> PathBuf {
160    use std::path::Component;
161
162    let mut result = PathBuf::new();
163
164    for component in path.components() {
165        match component {
166            Component::RootDir => {
167                result.push("/");
168            }
169            Component::Normal(name) => {
170                result.push(name);
171            }
172            Component::ParentDir => {
173                result.pop();
174            }
175            Component::CurDir => {
176                // Skip . components
177            }
178            Component::Prefix(_) => {
179                // Windows prefix, ignore
180            }
181        }
182    }
183
184    // Ensure we return "/" for empty result (e.g., from "/..")
185    if result.as_os_str().is_empty() {
186        result.push("/");
187    }
188
189    result
190}
191
192/// Execution context for builtin commands.
193///
194/// Provides access to the shell execution environment including arguments,
195/// variables, filesystem, and pipeline input.
196///
197/// # Example
198///
199/// ```rust
200/// use bashkit::{Builtin, BuiltinContext, ExecResult, async_trait};
201///
202/// struct Echo;
203///
204/// #[async_trait]
205/// impl Builtin for Echo {
206///     async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
207///         // Access command arguments
208///         let output = ctx.args.join(" ");
209///
210///         // Access environment variables
211///         let _home = ctx.env.get("HOME");
212///
213///         // Access pipeline input
214///         if let Some(stdin) = ctx.stdin {
215///             return Ok(ExecResult::ok(stdin.to_string()));
216///         }
217///
218///         Ok(ExecResult::ok(format!("{}\n", output)))
219///     }
220/// }
221/// ```
222pub struct Context<'a> {
223    /// Command arguments (not including the command name).
224    ///
225    /// For `mycommand arg1 arg2`, this contains `["arg1", "arg2"]`.
226    pub args: &'a [String],
227
228    /// Environment variables.
229    ///
230    /// Read-only access to variables set via [`BashBuilder::env`](crate::BashBuilder::env)
231    /// or the `export` builtin.
232    pub env: &'a HashMap<String, String>,
233
234    /// Shell variables (mutable).
235    ///
236    /// Allows builtins to set or modify shell variables.
237    #[allow(dead_code)] // Will be used by set, export, declare builtins
238    pub variables: &'a mut HashMap<String, String>,
239
240    /// Current working directory (mutable).
241    ///
242    /// Used by `cd` and path resolution.
243    pub cwd: &'a mut PathBuf,
244
245    /// Virtual filesystem.
246    ///
247    /// Provides async file operations (read, write, mkdir, etc.).
248    pub fs: Arc<dyn FileSystem>,
249
250    /// Standard input from pipeline.
251    ///
252    /// Contains output from the previous command in a pipeline.
253    /// For `echo hello | mycommand`, stdin will be `Some("hello\n")`.
254    pub stdin: Option<&'a str>,
255
256    /// HTTP client for network operations (curl, wget).
257    ///
258    /// Only available when the `network` feature is enabled and
259    /// a [`NetworkAllowlist`](crate::NetworkAllowlist) is configured via
260    /// [`BashBuilder::network`](crate::BashBuilder::network).
261    #[cfg(feature = "http_client")]
262    pub http_client: Option<&'a crate::network::HttpClient>,
263
264    /// Git client for git operations.
265    ///
266    /// Only available when the `git` feature is enabled and
267    /// a [`GitConfig`](crate::GitConfig) is configured via
268    /// [`BashBuilder::git`](crate::BashBuilder::git).
269    #[cfg(feature = "git")]
270    pub git_client: Option<&'a crate::git::GitClient>,
271}
272
273impl<'a> Context<'a> {
274    /// Create a new Context for testing purposes.
275    ///
276    /// This helper handles the conditional `http_client` field automatically.
277    #[cfg(test)]
278    pub fn new_for_test(
279        args: &'a [String],
280        env: &'a std::collections::HashMap<String, String>,
281        variables: &'a mut std::collections::HashMap<String, String>,
282        cwd: &'a mut std::path::PathBuf,
283        fs: std::sync::Arc<dyn crate::fs::FileSystem>,
284        stdin: Option<&'a str>,
285    ) -> Self {
286        Self {
287            args,
288            env,
289            variables,
290            cwd,
291            fs,
292            stdin,
293            #[cfg(feature = "http_client")]
294            http_client: None,
295            #[cfg(feature = "git")]
296            git_client: None,
297        }
298    }
299}
300
301/// Trait for implementing builtin commands.
302///
303/// All custom builtins must implement this trait. The trait requires `Send + Sync`
304/// for thread safety in async contexts.
305///
306/// # Example
307///
308/// ```rust
309/// use bashkit::{Bash, Builtin, BuiltinContext, ExecResult, async_trait};
310///
311/// struct Greet {
312///     default_name: String,
313/// }
314///
315/// #[async_trait]
316/// impl Builtin for Greet {
317///     async fn execute(&self, ctx: BuiltinContext<'_>) -> bashkit::Result<ExecResult> {
318///         let name = ctx.args.first()
319///             .map(|s| s.as_str())
320///             .unwrap_or(&self.default_name);
321///         Ok(ExecResult::ok(format!("Hello, {}!\n", name)))
322///     }
323/// }
324///
325/// // Register the builtin
326/// let bash = Bash::builder()
327///     .builtin("greet", Box::new(Greet { default_name: "World".into() }))
328///     .build();
329/// ```
330///
331/// # LLM Hints
332///
333/// Builtins can provide short hints for LLM system prompts via [`llm_hint`](Builtin::llm_hint).
334/// These appear in the tool's `help()` and `system_prompt()` output so LLMs know
335/// about capabilities and limitations.
336///
337/// # Return Values
338///
339/// Return [`ExecResult::ok`](crate::ExecResult::ok) for success with output,
340/// or [`ExecResult::err`](crate::ExecResult::err) for errors with exit code.
341#[async_trait]
342pub trait Builtin: Send + Sync {
343    /// Execute the builtin command.
344    ///
345    /// # Arguments
346    ///
347    /// * `ctx` - The execution context containing arguments, environment, and filesystem
348    ///
349    /// # Returns
350    ///
351    /// * `Ok(ExecResult)` - Execution result with stdout, stderr, and exit code
352    /// * `Err(Error)` - Fatal error that should abort execution
353    async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult>;
354
355    /// Optional short hint for LLM system prompts.
356    ///
357    /// Return a concise one-line description of capabilities and limitations.
358    /// These hints are included in `help()` and `system_prompt()` output
359    /// when the builtin is registered.
360    ///
361    /// # Example
362    ///
363    /// ```rust,ignore
364    /// fn llm_hint(&self) -> Option<&'static str> {
365    ///     Some("mycommand: Processes data files. Max 10MB input. No network access.")
366    /// }
367    /// ```
368    fn llm_hint(&self) -> Option<&'static str> {
369        None
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    #[test]
378    fn test_resolve_path_absolute() {
379        let cwd = PathBuf::from("/home/user");
380        let result = resolve_path(&cwd, "/tmp/file.txt");
381        assert_eq!(result, PathBuf::from("/tmp/file.txt"));
382    }
383
384    #[test]
385    fn test_resolve_path_relative() {
386        let cwd = PathBuf::from("/home/user");
387        let result = resolve_path(&cwd, "downloads/file.txt");
388        assert_eq!(result, PathBuf::from("/home/user/downloads/file.txt"));
389    }
390
391    #[test]
392    fn test_resolve_path_dot_from_root() {
393        // "." from root should normalize to "/"
394        let cwd = PathBuf::from("/");
395        let result = resolve_path(&cwd, ".");
396        assert_eq!(result, PathBuf::from("/"));
397    }
398
399    #[test]
400    fn test_resolve_path_dot_from_normal_dir() {
401        // "." should be stripped, returning the cwd itself
402        let cwd = PathBuf::from("/home/user");
403        let result = resolve_path(&cwd, ".");
404        assert_eq!(result, PathBuf::from("/home/user"));
405    }
406
407    #[test]
408    fn test_resolve_path_dotdot() {
409        // ".." should go up one directory
410        let cwd = PathBuf::from("/home/user");
411        let result = resolve_path(&cwd, "..");
412        assert_eq!(result, PathBuf::from("/home"));
413    }
414
415    #[test]
416    fn test_resolve_path_dotdot_from_root() {
417        // ".." from root stays at root
418        let cwd = PathBuf::from("/");
419        let result = resolve_path(&cwd, "..");
420        assert_eq!(result, PathBuf::from("/"));
421    }
422
423    #[test]
424    fn test_resolve_path_complex() {
425        // Complex path with . and ..
426        let cwd = PathBuf::from("/home/user");
427        let result = resolve_path(&cwd, "./downloads/../documents/./file.txt");
428        assert_eq!(result, PathBuf::from("/home/user/documents/file.txt"));
429    }
430}