use async_trait::async_trait;
use std::cmp::Ordering;
use std::path::Path;
use crate::ast::Value;
use crate::glob::contains_glob;
use crate::interpreter::{EntryType, ExecResult, OutputData, OutputNode};
use crate::tools::{ExecContext, ParamSchema, Tool, ToolArgs, ToolSchema};
use crate::vfs::DirEntry;
pub struct Ls;
#[async_trait]
impl Tool for Ls {
fn name(&self) -> &str {
"ls"
}
fn schema(&self) -> ToolSchema {
ToolSchema::new("ls", "List directory contents")
.param(ParamSchema::optional(
"path",
"string",
Value::String(".".into()),
"Directory path to list",
))
.param(ParamSchema::optional(
"long",
"bool",
Value::Bool(false),
"Use long format with details (-l)",
).with_aliases(["-l"]))
.param(ParamSchema::optional(
"all",
"bool",
Value::Bool(false),
"Show hidden files starting with . (-a)",
).with_aliases(["-a", "-A"]))
.param(ParamSchema::optional(
"one",
"bool",
Value::Bool(false),
"One entry per line (-1)",
).with_aliases(["-1"]))
.param(ParamSchema::optional(
"human",
"bool",
Value::Bool(false),
"Human-readable sizes (-h)",
).with_aliases(["-h"]))
.param(ParamSchema::optional(
"sort_time",
"bool",
Value::Bool(false),
"Sort by modification time (-t)",
).with_aliases(["-t"]))
.param(ParamSchema::optional(
"reverse",
"bool",
Value::Bool(false),
"Reverse sort order (-r)",
).with_aliases(["-r"]))
.param(ParamSchema::optional(
"sort_size",
"bool",
Value::Bool(false),
"Sort by file size (-S)",
).with_aliases(["-S"]))
.param(ParamSchema::optional(
"recursive",
"bool",
Value::Bool(false),
"List subdirectories recursively (-R)",
).with_aliases(["-R"]))
.example("List current directory", "ls")
.example("Show hidden files with details", "ls -la /path")
.example("Sort by size, largest first", "ls -lS /path")
.example("Human-readable sizes", "ls -lh /path")
.example("Recursive listing", "ls -R src/")
}
async fn execute(&self, args: ToolArgs, ctx: &mut ExecContext) -> ExecResult {
let path = args
.get_string("path", 0)
.unwrap_or_else(|| ".".to_string());
let resolved = ctx.resolve_path(&path);
let long_format = args.has_flag("long") || args.has_flag("l");
let show_all = args.has_flag("all") || args.has_flag("a");
let human_readable = args.has_flag("human") || args.has_flag("h");
let sort_time = args.has_flag("sort_time") || args.has_flag("t");
let sort_size = args.has_flag("sort_size") || args.has_flag("S");
let reverse = args.has_flag("reverse") || args.has_flag("r");
let recursive = args.has_flag("recursive") || args.has_flag("R");
let opts = ListOptions {
long_format,
human_readable,
show_all,
sort: SortOptions {
by_time: sort_time,
by_size: sort_size,
reverse,
},
};
if contains_glob(&path) {
return self.list_glob(ctx, &path, &opts).await;
}
if let Ok(info) = ctx.backend.stat(Path::new(&resolved)).await
&& !info.is_dir() {
return self.list_file(ctx, &path, &info, &opts);
}
if recursive {
self.list_recursive(ctx, &resolved, &opts).await
} else {
self.list_single(ctx, &path, &resolved, &opts).await
}
}
}
struct SortOptions {
by_time: bool,
by_size: bool,
reverse: bool,
}
struct ListOptions {
long_format: bool,
human_readable: bool,
show_all: bool,
sort: SortOptions,
}
impl Ls {
fn list_file(
&self,
_ctx: &mut ExecContext,
path: &str,
info: &DirEntry,
opts: &ListOptions,
) -> ExecResult {
let entry_type = dir_entry_to_type(info);
let node = if opts.long_format {
let type_char = if info.is_symlink() { "l" } else { "-" };
let size_str = if opts.human_readable {
format_human_size(info.size)
} else {
info.size.to_string()
};
OutputNode::new(path)
.with_cells(vec![type_char.to_string(), size_str])
.with_entry_type(entry_type)
} else {
OutputNode::new(path).with_entry_type(entry_type)
};
let output = if opts.long_format {
OutputData::table(
vec!["NAME".to_string(), "TYPE".to_string(), "SIZE".to_string()],
vec![node],
)
} else {
OutputData::nodes(vec![node])
};
ExecResult::with_output(output)
}
async fn list_glob(
&self,
ctx: &mut ExecContext,
pattern: &str,
opts: &ListOptions,
) -> ExecResult {
let patterns = [Value::String(pattern.to_string())];
let names = match ctx.expand_paths(&patterns).await {
Ok(n) => n,
Err(e) => return ExecResult::failure(1, format!("ls: {}", e)),
};
if names.is_empty() {
return ExecResult::with_output(OutputData::new());
}
let mut entries: Vec<(String, DirEntry)> = Vec::new();
for name in &names {
let abs = ctx.resolve_path(name);
match ctx.backend.stat(Path::new(&abs)).await {
Ok(info) => entries.push((name.clone(), info)),
Err(_) => {
}
}
}
let mut infos: Vec<DirEntry> = entries
.into_iter()
.map(|(name, mut info)| {
info.name = name;
info
})
.collect();
infos.sort_by(|a, b| {
let cmp = if opts.sort.by_time {
compare_by_time(a, b)
} else if opts.sort.by_size {
compare_by_size(a, b)
} else {
a.name.cmp(&b.name)
};
if opts.sort.reverse {
cmp.reverse()
} else {
cmp
}
});
let nodes: Vec<OutputNode> = infos
.iter()
.map(|e| {
let entry_type = dir_entry_to_type(e);
if opts.long_format {
let type_char = if e.is_symlink() {
"l"
} else if e.is_dir() {
"d"
} else {
"-"
};
let size_str = if opts.human_readable {
format_human_size(e.size)
} else {
e.size.to_string()
};
OutputNode::new(&e.name)
.with_cells(vec![type_char.to_string(), size_str])
.with_entry_type(entry_type)
} else {
OutputNode::new(&e.name).with_entry_type(entry_type)
}
})
.collect();
let output = if opts.long_format {
OutputData::table(
vec!["NAME".to_string(), "TYPE".to_string(), "SIZE".to_string()],
nodes,
)
} else {
OutputData::nodes(nodes)
};
ExecResult::with_output(output)
}
async fn list_single(
&self,
ctx: &mut ExecContext,
path: &str,
resolved: &Path,
opts: &ListOptions,
) -> ExecResult {
match ctx.backend.list(resolved).await {
Ok(entries) => {
let filtered = filter_and_sort(entries, opts.show_all, &opts.sort);
if filtered.is_empty() {
return ExecResult::with_output(OutputData::new());
}
let nodes: Vec<OutputNode> = filtered
.iter()
.map(|e| {
let entry_type = dir_entry_to_type(e);
let name_display = if e.is_symlink() {
if opts.long_format {
if let Some(target) = &e.symlink_target {
format!("{} -> {}", e.name, target.display())
} else {
format!("{}@", e.name)
}
} else {
format!("{}@", e.name)
}
} else {
e.name.clone()
};
if opts.long_format {
let type_char = if e.is_symlink() {
"l"
} else if e.is_dir() {
"d"
} else {
"-"
};
let size_str = if opts.human_readable {
format_human_size(e.size)
} else {
e.size.to_string()
};
OutputNode::new(name_display)
.with_cells(vec![type_char.to_string(), size_str])
.with_entry_type(entry_type)
} else {
OutputNode::new(name_display)
.with_entry_type(entry_type)
}
})
.collect();
let output = if opts.long_format {
OutputData::table(
vec!["NAME".to_string(), "TYPE".to_string(), "SIZE".to_string()],
nodes,
)
} else {
OutputData::nodes(nodes)
};
ExecResult::with_output(output)
}
Err(e) => ExecResult::failure(1, format!("ls: {}: {}", path, e)),
}
}
async fn list_recursive(
&self,
ctx: &mut ExecContext,
root: &Path,
opts: &ListOptions,
) -> ExecResult {
let mut text_output = String::new();
let mut dir_nodes: Vec<OutputNode> = Vec::new();
let mut dirs_to_visit: Vec<(String, String)> = vec![(
root.to_string_lossy().to_string(),
".".to_string(),
)];
let ignore_filter = ctx.build_ignore_filter(root).await;
while let Some((dir_path, display_path)) = dirs_to_visit.pop() {
let entries = match ctx.backend.list(Path::new(&dir_path)).await {
Ok(e) => e,
Err(_) => continue,
};
if !text_output.is_empty() {
text_output.push('\n');
}
text_output.push_str(&display_path);
text_output.push_str(":\n");
let mut filtered = filter_and_sort(entries, opts.show_all, &opts.sort);
if let Some(ref filter) = ignore_filter {
filtered.retain(|e| {
if e.is_dir() && !opts.show_all {
!filter.is_name_ignored(&e.name, true)
} else {
true
}
});
}
let subdirs: Vec<_> = filtered
.iter()
.filter(|e| e.is_dir())
.map(|e| {
let child_path = format!("{}/{}", dir_path.trim_end_matches('/'), e.name);
let child_display = if display_path == "." {
e.name.clone()
} else {
format!("{}/{}", display_path, e.name)
};
(child_path, child_display)
})
.collect();
let child_nodes: Vec<OutputNode> = filtered.iter().map(|e| {
let entry_type = dir_entry_to_type(e);
let name = e.name.clone();
if opts.long_format {
let type_char = if e.is_symlink() { "l" } else if e.is_dir() { "d" } else { "-" };
let size_str = if opts.human_readable {
format_human_size(e.size)
} else {
e.size.to_string()
};
OutputNode::new(name)
.with_cells(vec![type_char.to_string(), size_str])
.with_entry_type(entry_type)
} else {
OutputNode::new(name).with_entry_type(entry_type)
}
}).collect();
dir_nodes.push(
OutputNode::new(&display_path)
.with_entry_type(EntryType::Directory)
.with_children(child_nodes)
);
let lines = format_entries(&filtered, opts.long_format, opts.human_readable);
text_output.push_str(&lines.join("\n"));
if !lines.is_empty() {
text_output.push('\n');
}
for subdir in subdirs.into_iter().rev() {
dirs_to_visit.push(subdir);
}
}
let output = if opts.long_format {
OutputData::table(
vec!["NAME".to_string(), "TYPE".to_string(), "SIZE".to_string()],
dir_nodes,
)
} else {
OutputData::nodes(dir_nodes)
};
ExecResult::with_output_and_text(output, text_output.trim_end().to_string())
}
}
fn filter_and_sort(entries: Vec<DirEntry>, show_all: bool, sort_opts: &SortOptions) -> Vec<DirEntry> {
let mut filtered: Vec<_> = entries
.into_iter()
.filter(|e| show_all || !e.name.starts_with('.'))
.collect();
filtered.sort_by(|a, b| {
let cmp = if sort_opts.by_time {
compare_by_time(a, b)
} else if sort_opts.by_size {
compare_by_size(a, b)
} else {
a.name.cmp(&b.name)
};
if sort_opts.reverse {
cmp.reverse()
} else {
cmp
}
});
filtered
}
fn dir_entry_to_type(entry: &DirEntry) -> EntryType {
if entry.is_symlink() {
EntryType::Symlink
} else if entry.is_dir() {
EntryType::Directory
} else if entry.permissions.map(|p| p & 0o111 != 0).unwrap_or(false) {
EntryType::Executable
} else {
EntryType::File
}
}
fn format_entries(entries: &[DirEntry], long_format: bool, human_readable: bool) -> Vec<String> {
if long_format {
entries
.iter()
.map(|e| {
let type_char = if e.is_symlink() {
'l'
} else if e.is_dir() {
'd'
} else {
'-'
};
let size_str = if human_readable {
format_human_size(e.size)
} else {
e.size.to_string()
};
let name_display = if e.is_symlink() {
if let Some(target) = &e.symlink_target {
format!("{} -> {}", e.name, target.display())
} else {
format!("{}@", e.name)
}
} else {
e.name.clone()
};
format!("{} {} {}", type_char, size_str, name_display)
})
.collect()
} else {
entries.iter().map(|e| {
if e.is_symlink() {
format!("{}@", e.name)
} else {
e.name.clone()
}
}).collect()
}
}
fn compare_by_time(a: &DirEntry, b: &DirEntry) -> Ordering {
match (a.modified, b.modified) {
(Some(ta), Some(tb)) => tb.cmp(&ta), (Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => a.name.cmp(&b.name),
}
}
fn compare_by_size(a: &DirEntry, b: &DirEntry) -> Ordering {
b.size.cmp(&a.size) }
fn format_human_size(bytes: u64) -> String {
const UNITS: &[&str] = &["", "K", "M", "G", "T", "P"];
let mut size = bytes as f64;
let mut unit_idx = 0;
while size >= 1024.0 && unit_idx < UNITS.len() - 1 {
size /= 1024.0;
unit_idx += 1;
}
if unit_idx == 0 {
bytes.to_string()
} else if size >= 10.0 {
format!("{:.0}{}", size, UNITS[unit_idx])
} else {
format!("{:.1}{}", size, UNITS[unit_idx])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vfs::{Filesystem, MemoryFs, VfsRouter};
use std::sync::Arc;
async fn make_ctx() -> ExecContext {
let mut vfs = VfsRouter::new();
let mem = MemoryFs::new();
mem.write(Path::new("file1.txt"), b"a").await.unwrap();
mem.write(Path::new("file2.txt"), b"b").await.unwrap();
mem.mkdir(Path::new("subdir")).await.unwrap();
vfs.mount("/", mem);
ExecContext::new(Arc::new(vfs))
}
#[tokio::test]
async fn test_ls_root() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/".into()));
let result = Ls.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("file1.txt"));
assert!(result.text_out().contains("file2.txt"));
assert!(result.text_out().contains("subdir"));
}
#[tokio::test]
async fn test_ls_long() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/".into()));
args.named.insert("long".to_string(), Value::Bool(true));
let result = Ls.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("subdir"));
assert!(result.text_out().contains("file1.txt"));
match result.output() {
Some(output) => {
assert!(output.headers.is_some());
assert!(!output.root.is_empty());
assert!(output.root.iter().any(|n| n.cells.len() == 2));
}
None => panic!("Expected OutputData for long format"),
}
}
#[tokio::test]
async fn test_ls_cwd() {
let mut ctx = make_ctx().await;
let args = ToolArgs::new();
let result = Ls.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("file1.txt"));
}
#[tokio::test]
async fn test_ls_not_found() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/nonexistent".into()));
let result = Ls.execute(args, &mut ctx).await;
assert!(!result.ok());
}
async fn make_ctx_with_hidden() -> ExecContext {
let mut vfs = VfsRouter::new();
let mem = MemoryFs::new();
mem.write(Path::new("visible.txt"), b"a").await.unwrap();
mem.write(Path::new(".hidden"), b"b").await.unwrap();
mem.mkdir(Path::new(".config")).await.unwrap();
vfs.mount("/", mem);
ExecContext::new(Arc::new(vfs))
}
#[tokio::test]
async fn test_ls_hides_dotfiles_by_default() {
let mut ctx = make_ctx_with_hidden().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/".into()));
let result = Ls.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("visible.txt"));
assert!(!result.text_out().contains(".hidden"));
assert!(!result.text_out().contains(".config"));
}
#[tokio::test]
async fn test_ls_a_shows_hidden() {
let mut ctx = make_ctx_with_hidden().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/".into()));
args.flags.insert("a".to_string());
let result = Ls.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("visible.txt"));
assert!(result.text_out().contains(".hidden"));
assert!(result.text_out().contains(".config"));
}
#[tokio::test]
async fn test_ls_all_shows_hidden() {
let mut ctx = make_ctx_with_hidden().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/".into()));
args.flags.insert("all".to_string());
let result = Ls.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains(".hidden"));
}
async fn make_ctx_with_subdirs() -> ExecContext {
let mut vfs = VfsRouter::new();
let mem = MemoryFs::new();
mem.mkdir(Path::new("src")).await.unwrap();
mem.mkdir(Path::new("src/lib")).await.unwrap();
mem.write(Path::new("src/main.rs"), b"main").await.unwrap();
mem.write(Path::new("src/lib/utils.rs"), b"utils")
.await
.unwrap();
mem.write(Path::new("README.md"), b"readme").await.unwrap();
vfs.mount("/", mem);
ExecContext::new(Arc::new(vfs))
}
#[tokio::test]
async fn test_ls_recursive() {
let mut ctx = make_ctx_with_subdirs().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/".into()));
args.flags.insert("R".to_string());
let result = Ls.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains(".:"));
assert!(result.text_out().contains("README.md"));
assert!(result.text_out().contains("src"));
assert!(result.text_out().contains("main.rs"));
assert!(result.text_out().contains("lib"));
}
#[test]
fn test_dir_entry_to_type_directory() {
let entry = DirEntry::directory("mydir");
assert_eq!(dir_entry_to_type(&entry), EntryType::Directory);
}
#[test]
fn test_dir_entry_to_type_file() {
let entry = DirEntry::file("test.txt", 100);
assert_eq!(dir_entry_to_type(&entry), EntryType::File);
}
#[test]
fn test_dir_entry_to_type_executable() {
let mut entry = DirEntry::file("script.sh", 100);
entry.permissions = Some(0o755); assert_eq!(dir_entry_to_type(&entry), EntryType::Executable);
}
#[test]
fn test_dir_entry_to_type_file_no_execute() {
let mut entry = DirEntry::file("data.txt", 100);
entry.permissions = Some(0o644); assert_eq!(dir_entry_to_type(&entry), EntryType::File);
}
#[test]
fn test_dir_entry_to_type_symlink() {
let entry = DirEntry::symlink("link.txt", "target.txt");
assert_eq!(dir_entry_to_type(&entry), EntryType::Symlink);
}
async fn make_ctx_with_symlinks() -> ExecContext {
let mut vfs = VfsRouter::new();
let mem = MemoryFs::new();
mem.write(Path::new("target.txt"), b"content").await.unwrap();
mem.mkdir(Path::new("targetdir")).await.unwrap();
mem.symlink(Path::new("target.txt"), Path::new("link.txt"))
.await
.unwrap();
mem.symlink(Path::new("targetdir"), Path::new("linkdir"))
.await
.unwrap();
mem.symlink(Path::new("nonexistent"), Path::new("broken"))
.await
.unwrap();
vfs.mount("/", mem);
ExecContext::new(Arc::new(vfs))
}
#[tokio::test]
async fn test_ls_shows_symlinks() {
let mut ctx = make_ctx_with_symlinks().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/".into()));
let result = Ls.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("link.txt@"), "output was: {}", result.text_out());
assert!(result.text_out().contains("linkdir@"), "output was: {}", result.text_out());
assert!(result.text_out().contains("broken@"), "output was: {}", result.text_out());
}
#[tokio::test]
async fn test_ls_long_shows_symlink_targets() {
let mut ctx = make_ctx_with_symlinks().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/".into()));
args.named.insert("long".to_string(), Value::Bool(true));
let result = Ls.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("link.txt -> target.txt"));
assert!(result.text_out().contains("linkdir -> targetdir"));
assert!(result.text_out().contains("broken -> nonexistent"));
match result.output() {
Some(output) => {
let has_symlink_type = output.root.iter().any(|n| n.cells.get(0) == Some(&"l".to_string()));
assert!(has_symlink_type, "Expected 'l' type character for symlinks");
}
None => panic!("Expected OutputData for long format"),
}
}
#[tokio::test]
async fn test_ls_symlink_in_recursive() {
let mut ctx = make_ctx_with_symlinks().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/".into()));
args.flags.insert("R".to_string());
let result = Ls.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("link.txt@"));
}
#[tokio::test]
async fn test_ls_glob_txt() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("*.txt".into()));
let result = Ls.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("file1.txt"));
assert!(result.text_out().contains("file2.txt"));
assert!(!result.text_out().contains("subdir"));
}
#[tokio::test]
async fn test_ls_glob_scoped_subdir() {
let mut ctx = make_ctx_with_subdirs().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("src/*.rs".into()));
let result = Ls.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("main.rs"));
assert!(!result.text_out().contains("README.md"));
}
#[tokio::test]
async fn test_ls_glob_long_format() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("*.txt".into()));
args.named.insert("long".to_string(), Value::Bool(true));
let result = Ls.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("file1.txt"));
assert!(result.has_output());
let output = result.output().cloned().unwrap();
assert!(output.headers.is_some());
}
#[tokio::test]
async fn test_ls_glob_no_matches() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("*.nonexistent".into()));
let result = Ls.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().is_empty());
}
}