From 7d1a46541b6a405cad7e28c8157ba2454a5c6c17 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Sun, 28 Jun 2026 09:23:05 +0000 Subject: [PATCH] feat(ls): support -d/--directory to list directories themselves Implements `ls -d` / `ls --directory`, which lists directory entries themselves rather than descending into their contents (POSIX). This unblocks the very common `ls -d */` idiom for enumerating subdirectories. Directory arguments are treated like ordinary non-directory entries when -d is set; `-F` still appends the trailing `/` because the classify suffix keys off the entry metadata. Closes #2127 --- crates/bashkit/docs/compatibility.md | 2 +- crates/bashkit/src/builtins/ls/list.rs | 12 ++- crates/bashkit/src/builtins/ls/tests.rs | 136 ++++++++++++++++++++++++ 3 files changed, 147 insertions(+), 3 deletions(-) diff --git a/crates/bashkit/docs/compatibility.md b/crates/bashkit/docs/compatibility.md index 1a0b496a3..55ee4cbd1 100644 --- a/crates/bashkit/docs/compatibility.md +++ b/crates/bashkit/docs/compatibility.md @@ -96,7 +96,7 @@ for sandbox security reasons. See the compliance spec for details. | `curl` | `-s`, `-o`, `-X`, `-d`, `-H`, `-I`, `-f`, `-L`, `-w`, `--compressed`, `-u`, `-A`, `-e`, `-v`, `-m` | HTTP client (requires http_client feature) | | `wget` | `-q`, `-O`, `--spider`, `--header`, `-U`, `--post-data`, `-t` | Download files (requires http_client feature) | | `timeout` | `DURATION COMMAND` | Run with time limit (stub) | -| `ls` | `-l`, `-a`, `-h`, `-1`, `-R` | List directory contents | +| `ls` | `-l`, `-a`, `-h`, `-1`, `-R`, `-t`, `-F`, `-C`, `-d` | List directory contents | | `find` | `-name`, `-type`, `-maxdepth`, `-print` | Search for files | | `rmdir` | `-p` | Remove empty directories | | `xargs` | `-I`, `-n`, `-d` | Build commands from stdin | diff --git a/crates/bashkit/src/builtins/ls/list.rs b/crates/bashkit/src/builtins/ls/list.rs index f06804b21..00a78570d 100644 --- a/crates/bashkit/src/builtins/ls/list.rs +++ b/crates/bashkit/src/builtins/ls/list.rs @@ -28,6 +28,7 @@ const LS_SUPPORTED_IDS: &[&str] = &[ "t", // -t "classify", // -F / --classify "C", // -C + "directory", // -d / --directory // Non-flag positional + always-supported infrastructure. "paths", "help", @@ -43,11 +44,12 @@ pub(super) struct LsOptions { pub(super) sort_by_time: bool, pub(super) classify: bool, pub(super) columns: bool, + pub(super) directory: bool, } /// The ls builtin - list directory contents. /// -/// Usage: ls [-l] [-a] [-h] [-1] [-R] [-t] [-F] [-C] [PATH...] +/// Usage: ls [-l] [-a] [-h] [-1] [-R] [-t] [-F] [-C] [-d] [PATH...] /// /// Options: /// -l Use long listing format @@ -58,6 +60,7 @@ pub(super) struct LsOptions { /// -t Sort by modification time, newest first /// -F Append indicator (/ for dirs, * for executables, @ for symlinks, | for FIFOs) /// -C List entries in columns (multi-column output) +/// -d List directories themselves, not their contents pub struct Ls; #[async_trait] @@ -134,6 +137,7 @@ impl Builtin for Ls { sort_by_time: matches.get_flag("t"), classify, columns: matches.get_flag("C"), + directory: matches.get_flag("directory"), }; // PATHS holds OsString values; convert to owned strings for the @@ -172,7 +176,11 @@ impl Builtin for Ls { let metadata = ctx.fs.stat(&path).await?; - if metadata.file_type.is_file() { + // With -d/--directory, list the directory entry itself rather than + // descending into it: treat directory arguments like ordinary + // non-directory entries. classify_suffix() still appends "/" under + // -F because it keys off the metadata. (POSIX; common `ls -d */`.) + if metadata.file_type.is_file() || opts.directory { file_args.push((path_str, metadata)); } else { dir_args.push((i, path_str, path)); diff --git a/crates/bashkit/src/builtins/ls/tests.rs b/crates/bashkit/src/builtins/ls/tests.rs index b6772143f..fcfd43993 100644 --- a/crates/bashkit/src/builtins/ls/tests.rs +++ b/crates/bashkit/src/builtins/ls/tests.rs @@ -2706,3 +2706,139 @@ async fn test_find_printf_rejects_oversized_aggregate_output() { result.stdout.len() ); } + +// ==================== ls -d / --directory tests ==================== + +/// `ls -d DIR` lists the directory entry itself, not its contents. +#[tokio::test] +async fn test_ls_directory_flag() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + fs.mkdir(&cwd.join("subdir"), false).await.unwrap(); + fs.write_file(&cwd.join("subdir/inner.txt"), b"x") + .await + .unwrap(); + + let args = vec!["-d".to_string(), "subdir".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr); + assert_eq!(result.stdout, "subdir\n"); + // The directory's contents must NOT be listed. + assert!(!result.stdout.contains("inner.txt")); +} + +/// `--directory` long form behaves like `-d`. +#[tokio::test] +async fn test_ls_directory_long_flag() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + fs.mkdir(&cwd.join("d1"), false).await.unwrap(); + + let args = vec!["--directory".to_string(), "d1".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr); + assert_eq!(result.stdout, "d1\n"); +} + +/// `ls -d` of multiple directory args (the `ls -d */` idiom, after the +/// shell expands the glob) lists each directory entry, not their contents. +#[tokio::test] +async fn test_ls_directory_multiple() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + fs.mkdir(&cwd.join("alpha"), false).await.unwrap(); + fs.mkdir(&cwd.join("beta"), false).await.unwrap(); + fs.write_file(&cwd.join("alpha/inner.txt"), b"x") + .await + .unwrap(); + + let args = vec!["-d".to_string(), "alpha".to_string(), "beta".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr); + assert!(result.stdout.contains("alpha")); + assert!(result.stdout.contains("beta")); + // No header lines and no descent into contents. + assert!(!result.stdout.contains("alpha:")); + assert!(!result.stdout.contains("inner.txt")); +} + +/// `ls -dF DIR` appends the `/` classify suffix to the directory name. +#[tokio::test] +async fn test_ls_directory_classify() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + fs.mkdir(&cwd.join("mydir"), false).await.unwrap(); + + let args = vec!["-d".to_string(), "-F".to_string(), "mydir".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0, "stderr: {}", result.stderr); + assert_eq!(result.stdout, "mydir/\n"); +}