Skip to content

Commit 3a10575

Browse files
authored
feat(builtins): add readlink command (#579)
## Summary - Add `readlink` builtin with `-f`, `-m`, `-e` canonicalization modes and raw symlink target reading - Register in interpreter alongside `basename`, `dirname`, `realpath` - 10 unit tests covering symlinks, canonicalization, missing paths, and error cases ## Test plan - [x] Unit tests for all modes: raw, -f, -m, -e - [x] Error cases: missing operand, invalid option, non-symlink, nonexistent path - [x] `cargo clippy --all-targets --all-features -- -D warnings` passes - [x] Full test suite passes (1611+ tests) Closes #537
1 parent 3abf1f5 commit 3a10575

3 files changed

Lines changed: 239 additions & 1 deletion

File tree

crates/bashkit/src/builtins/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ pub use ls::{Find, Ls, Rmdir};
106106
pub use navigation::{Cd, Pwd};
107107
pub use nl::Nl;
108108
pub use paste::Paste;
109-
pub use path::{Basename, Dirname, Realpath};
109+
pub use path::{Basename, Dirname, Readlink, Realpath};
110110
pub use pipeline::{Tee, Watch, Xargs};
111111
pub use printf::Printf;
112112
pub use read::Read;

crates/bashkit/src/builtins/path.rs

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,137 @@ impl Builtin for Realpath {
150150
}
151151
}
152152

153+
/// The readlink builtin - print resolved symbolic links or canonical file names.
154+
///
155+
/// Usage: readlink [-f|-m|-e] FILE...
156+
///
157+
/// Options:
158+
/// -f canonicalize: follow symlinks, resolve `.`/`..`; all but last component must exist
159+
/// -m canonicalize-missing: like -f but no component needs to exist
160+
/// -e canonicalize-existing: like -f but all components must exist
161+
/// (no flag) print symlink target without canonicalization
162+
pub struct Readlink;
163+
164+
#[async_trait]
165+
impl Builtin for Readlink {
166+
#[allow(clippy::collapsible_if)]
167+
async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
168+
if ctx.args.is_empty() {
169+
return Ok(ExecResult::err(
170+
"readlink: missing operand\n".to_string(),
171+
1,
172+
));
173+
}
174+
175+
let mut mode = ReadlinkMode::Raw;
176+
let mut files: Vec<&str> = Vec::new();
177+
178+
for arg in ctx.args {
179+
match arg.as_str() {
180+
"-f" => mode = ReadlinkMode::Canonicalize,
181+
"-m" => mode = ReadlinkMode::CanonicalizeMissing,
182+
"-e" => mode = ReadlinkMode::CanonicalizeExisting,
183+
"-n" | "-v" | "-q" | "-s" | "--no-newline" => { /* silently accept */ }
184+
s if s.starts_with('-') && s.len() > 1 && !s.starts_with("--") => {
185+
// Could be combined flags like -fn
186+
for ch in s[1..].chars() {
187+
match ch {
188+
'f' => mode = ReadlinkMode::Canonicalize,
189+
'm' => mode = ReadlinkMode::CanonicalizeMissing,
190+
'e' => mode = ReadlinkMode::CanonicalizeExisting,
191+
'n' | 'v' | 'q' | 's' => {}
192+
_ => {
193+
return Ok(ExecResult::err(
194+
format!("readlink: invalid option -- '{}'\n", ch),
195+
1,
196+
));
197+
}
198+
}
199+
}
200+
}
201+
_ => files.push(arg),
202+
}
203+
}
204+
205+
if files.is_empty() {
206+
return Ok(ExecResult::err(
207+
"readlink: missing operand\n".to_string(),
208+
1,
209+
));
210+
}
211+
212+
let mut output = String::new();
213+
let mut exit_code = 0;
214+
215+
for file in &files {
216+
let resolved = super::resolve_path(ctx.cwd, file);
217+
218+
match mode {
219+
ReadlinkMode::Raw => {
220+
// No flag: read symlink target
221+
match ctx.fs.read_link(&resolved).await {
222+
Ok(target) => {
223+
output.push_str(&target.to_string_lossy());
224+
output.push('\n');
225+
}
226+
Err(_) => {
227+
exit_code = 1;
228+
}
229+
}
230+
}
231+
ReadlinkMode::Canonicalize | ReadlinkMode::CanonicalizeMissing => {
232+
// -f and -m: canonicalize path (resolve . and ..)
233+
// -m doesn't require existence, -f requires all but last
234+
let parent_missing = if mode == ReadlinkMode::Canonicalize {
235+
resolved
236+
.parent()
237+
.filter(|p| !p.as_os_str().is_empty())
238+
.map(|p| ctx.fs.exists(p))
239+
} else {
240+
None
241+
};
242+
if let Some(fut) = parent_missing {
243+
if !fut.await.unwrap_or(false) {
244+
exit_code = 1;
245+
continue;
246+
}
247+
}
248+
output.push_str(&resolved.to_string_lossy());
249+
output.push('\n');
250+
}
251+
ReadlinkMode::CanonicalizeExisting => {
252+
// -e: all components must exist
253+
if ctx.fs.exists(&resolved).await.unwrap_or(false) {
254+
output.push_str(&resolved.to_string_lossy());
255+
output.push('\n');
256+
} else {
257+
exit_code = 1;
258+
}
259+
}
260+
}
261+
}
262+
263+
if exit_code != 0 && output.is_empty() {
264+
Ok(ExecResult::err(String::new(), exit_code))
265+
} else if exit_code != 0 {
266+
// Some files succeeded, some failed
267+
let mut result = ExecResult::with_code(output, exit_code);
268+
result.exit_code = exit_code;
269+
Ok(result)
270+
} else {
271+
Ok(ExecResult::ok(output))
272+
}
273+
}
274+
}
275+
276+
#[derive(PartialEq)]
277+
enum ReadlinkMode {
278+
Raw,
279+
Canonicalize,
280+
CanonicalizeMissing,
281+
CanonicalizeExisting,
282+
}
283+
153284
#[cfg(test)]
154285
#[allow(clippy::unwrap_used)]
155286
mod tests {
@@ -282,4 +413,110 @@ mod tests {
282413
assert_eq!(result.exit_code, 1);
283414
assert!(result.stderr.contains("missing operand"));
284415
}
416+
417+
// readlink tests
418+
419+
use crate::fs::FileSystem;
420+
421+
async fn run_readlink_with_fs(args: &[&str], fs: Arc<dyn FileSystem>) -> ExecResult {
422+
let mut variables = HashMap::new();
423+
let env = HashMap::new();
424+
let mut cwd = PathBuf::from("/");
425+
426+
let args: Vec<String> = args.iter().map(|s| s.to_string()).collect();
427+
let ctx = Context {
428+
args: &args,
429+
env: &env,
430+
variables: &mut variables,
431+
cwd: &mut cwd,
432+
fs,
433+
stdin: None,
434+
#[cfg(feature = "http_client")]
435+
http_client: None,
436+
#[cfg(feature = "git")]
437+
git_client: None,
438+
};
439+
440+
Readlink.execute(ctx).await.unwrap()
441+
}
442+
443+
#[tokio::test]
444+
async fn test_readlink_missing_operand() {
445+
let fs = Arc::new(InMemoryFs::new()) as Arc<dyn FileSystem>;
446+
let result = run_readlink_with_fs(&[], fs).await;
447+
assert_eq!(result.exit_code, 1);
448+
assert!(result.stderr.contains("missing operand"));
449+
}
450+
451+
#[tokio::test]
452+
async fn test_readlink_raw_symlink() {
453+
let fs = Arc::new(InMemoryFs::new()) as Arc<dyn FileSystem>;
454+
fs.symlink(Path::new("/target"), Path::new("/link"))
455+
.await
456+
.unwrap();
457+
let result = run_readlink_with_fs(&["/link"], fs).await;
458+
assert_eq!(result.exit_code, 0);
459+
assert_eq!(result.stdout, "/target\n");
460+
}
461+
462+
#[tokio::test]
463+
async fn test_readlink_raw_not_symlink() {
464+
let fs = Arc::new(InMemoryFs::new()) as Arc<dyn FileSystem>;
465+
fs.write_file(Path::new("/file"), b"data").await.unwrap(); // write a regular file
466+
let result = run_readlink_with_fs(&["/file"], fs).await;
467+
// Not a symlink → failure, no output
468+
assert_eq!(result.exit_code, 1);
469+
assert!(result.stdout.is_empty());
470+
}
471+
472+
#[tokio::test]
473+
async fn test_readlink_raw_nonexistent() {
474+
let fs = Arc::new(InMemoryFs::new()) as Arc<dyn FileSystem>;
475+
let result = run_readlink_with_fs(&["/nonexistent"], fs).await;
476+
assert_eq!(result.exit_code, 1);
477+
}
478+
479+
#[tokio::test]
480+
async fn test_readlink_f_canonicalize() {
481+
let fs = Arc::new(InMemoryFs::new()) as Arc<dyn FileSystem>;
482+
fs.mkdir(Path::new("/home"), true).await.unwrap();
483+
fs.mkdir(Path::new("/home/user"), true).await.unwrap();
484+
let result = run_readlink_with_fs(&["-f", "/home/user/../user/./file"], fs).await;
485+
assert_eq!(result.exit_code, 0);
486+
assert_eq!(result.stdout, "/home/user/file\n");
487+
}
488+
489+
#[tokio::test]
490+
async fn test_readlink_m_canonicalize_missing() {
491+
let fs = Arc::new(InMemoryFs::new()) as Arc<dyn FileSystem>;
492+
// -m doesn't require existence
493+
let result = run_readlink_with_fs(&["-m", "/a/b/../c"], fs).await;
494+
assert_eq!(result.exit_code, 0);
495+
assert_eq!(result.stdout, "/a/c\n");
496+
}
497+
498+
#[tokio::test]
499+
async fn test_readlink_e_existing() {
500+
let fs = Arc::new(InMemoryFs::new()) as Arc<dyn FileSystem>;
501+
fs.mkdir(Path::new("/existing"), false).await.unwrap();
502+
let result = run_readlink_with_fs(&["-e", "/existing"], fs).await;
503+
assert_eq!(result.exit_code, 0);
504+
assert_eq!(result.stdout, "/existing\n");
505+
}
506+
507+
#[tokio::test]
508+
async fn test_readlink_e_nonexistent() {
509+
let fs = Arc::new(InMemoryFs::new()) as Arc<dyn FileSystem>;
510+
let result = run_readlink_with_fs(&["-e", "/nonexistent"], fs).await;
511+
assert_eq!(result.exit_code, 1);
512+
assert!(result.stdout.is_empty());
513+
}
514+
515+
#[tokio::test]
516+
async fn test_readlink_invalid_option() {
517+
let fs = Arc::new(InMemoryFs::new()) as Arc<dyn FileSystem>;
518+
let result = run_readlink_with_fs(&["-z", "/file"], fs).await;
519+
assert_eq!(result.exit_code, 1);
520+
assert!(result.stderr.contains("invalid option"));
521+
}
285522
}

crates/bashkit/src/interpreter/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,7 @@ impl Interpreter {
372372
builtins.insert("basename".to_string(), Box::new(builtins::Basename));
373373
builtins.insert("dirname".to_string(), Box::new(builtins::Dirname));
374374
builtins.insert("realpath".to_string(), Box::new(builtins::Realpath));
375+
builtins.insert("readlink".to_string(), Box::new(builtins::Readlink));
375376
builtins.insert("mkdir".to_string(), Box::new(builtins::Mkdir));
376377
builtins.insert("mktemp".to_string(), Box::new(builtins::Mktemp));
377378
builtins.insert("rm".to_string(), Box::new(builtins::Rm));

0 commit comments

Comments
 (0)