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}