diff --git a/crates/bashkit/src/builtins/ls/list.rs b/crates/bashkit/src/builtins/ls/list.rs index f06804b2..fbdaada5 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,6 +44,7 @@ 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. @@ -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,10 @@ impl Builtin for Ls { let metadata = ctx.fs.stat(&path).await?; - if metadata.file_type.is_file() { + // With -d, a directory operand is listed as itself (like a file + // argument) rather than being descended into. POSIX: enables the + // common `ls -d */` idiom to enumerate subdirectories. + 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 b6772143..c50176b7 100644 --- a/crates/bashkit/src/builtins/ls/tests.rs +++ b/crates/bashkit/src/builtins/ls/tests.rs @@ -310,6 +310,103 @@ async fn test_ls_file() { assert!(result.stdout.contains("test.txt")); } +#[tokio::test] +async fn test_ls_directory_flag_lists_dir_itself() { + // `ls -d DIR` prints the directory operand, not its contents (issue #2127). + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + fs.mkdir(&cwd.join("sub"), false).await.unwrap(); + fs.write_file(&cwd.join("sub/inside.txt"), b"x") + .await + .unwrap(); + + let args = vec!["-d".to_string(), "sub".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); + assert_eq!(result.stdout, "sub\n"); + assert!(!result.stdout.contains("inside.txt")); +} + +#[tokio::test] +async fn test_ls_directory_flag_long_format() { + // `ls -ld DIR` shows the directory's own long entry (type 'd'). + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + fs.mkdir(&cwd.join("sub"), false).await.unwrap(); + + let args = vec!["-ld".to_string(), "sub".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); + assert!(result.stdout.starts_with('d'), "got: {:?}", result.stdout); // debug-ok: test-only assertion message, not builtin stderr + assert!(result.stdout.trim_end().ends_with("sub")); +} + +#[tokio::test] +async fn test_ls_directory_flag_multiple_dirs() { + // The `ls -d a/ b/` idiom: each directory operand printed as-is. + 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(); + + 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); + assert!(result.stdout.contains("alpha")); + assert!(result.stdout.contains("beta")); +} + #[tokio::test] async fn test_ls_recursive() { let (fs, mut cwd, mut variables) = create_test_ctx().await;