use async_trait::async_trait;
use clap::{CommandFactory, Parser};
use std::path::Path;
#[cfg(test)]
use crate::ast::Value;
use crate::interpreter::{ExecResult, OutputData};
use crate::tools::{schema_from_clap, ExecContext, ToolCtx, GlobalFlags, Tool, ToolArgs, ToolSchema};
pub struct Cat;
#[derive(Parser, Debug)]
#[command(name = "cat", about = "Read and output file contents")]
struct CatArgs {
#[arg(short = 'n', long = "number")]
number: bool,
#[command(flatten)]
global: GlobalFlags,
paths: Vec<String>,
}
#[async_trait]
impl Tool for Cat {
fn name(&self) -> &str {
"cat"
}
fn schema(&self) -> ToolSchema {
schema_from_clap(
&CatArgs::command(),
"cat",
"Read and output file contents",
[
("Read a file", "cat README.md"),
("Show line numbers", "cat -n src/main.rs"),
("Concatenate files", "cat header.txt body.txt"),
],
)
}
async fn execute(&self, args: ToolArgs, ctx: &mut dyn ToolCtx) -> ExecResult {
let Some(ctx) = ctx.as_any_mut().downcast_mut::<ExecContext>() else {
return ExecResult::failure(1, "internal error: kernel builtin requires ExecContext");
};
let parsed = match CatArgs::try_parse_from(
std::iter::once("cat".to_string()).chain(args.to_argv()),
) {
Ok(p) => p,
Err(e) => return ExecResult::failure(2, format!("cat: {e}")),
};
parsed.global.apply(ctx);
let number_lines = parsed.number;
if args.positional.is_empty() {
if !number_lines && ctx.pipe_stdin.is_some() && ctx.pipe_stdout.is_some() {
if let (Some(mut pipe_in), Some(mut pipe_out)) =
(ctx.pipe_stdin.take(), ctx.pipe_stdout.take())
{
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut buf = [0u8; 8192];
loop {
match pipe_in.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
if pipe_out.write_all(&buf[..n]).await.is_err() {
break; }
}
Err(_) => break,
}
}
let _ = pipe_out.shutdown().await;
return ExecResult::success("");
}
}
let stdin = ctx.read_stdin_to_string().await.unwrap_or_default();
if number_lines && !stdin.is_empty() {
let numbered = stdin
.lines()
.enumerate()
.map(|(i, line)| format!("{:6}\t{}", i + 1, line))
.collect::<Vec<_>>()
.join("\n");
return ExecResult::with_output(OutputData::text(numbered));
}
return ExecResult::with_output(OutputData::text(stdin));
}
let paths = match ctx.expand_paths(&args.positional).await {
Ok(p) => p,
Err(e) => return ExecResult::failure(1, format!("cat: {}", e)),
};
if paths.is_empty() {
return ExecResult::failure(1, "cat: missing path argument");
}
let mut all_content = String::new();
let mut line_num = 1;
for (i, path) in paths.iter().enumerate() {
let resolved = ctx.resolve_path(path);
match ctx.backend.read(Path::new(&resolved), None).await {
Ok(data) => match String::from_utf8(data) {
Ok(content) => {
if number_lines {
for line in content.lines() {
if !all_content.is_empty() {
all_content.push('\n');
}
all_content.push_str(&format!("{:6}\t{}", line_num, line));
line_num += 1;
}
} else {
if i > 0 && !all_content.is_empty() {
all_content.push('\n');
}
all_content.push_str(&content);
}
}
Err(_) => return ExecResult::failure(1, format!("cat: {}: invalid UTF-8", path)),
},
Err(e) => return ExecResult::failure(1, format!("cat: {}: {}", path, e)),
}
}
ExecResult::with_output(OutputData::text(all_content))
}
}
#[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("test.txt"), b"hello world").await.unwrap();
mem.write(Path::new("dir/nested.txt"), b"nested content").await.unwrap();
mem.write(Path::new("lines.txt"), b"line1\nline2\nline3").await.unwrap();
mem.write(Path::new("other.txt"), b"other content").await.unwrap();
vfs.mount("/", mem);
ExecContext::new(Arc::new(vfs))
}
#[tokio::test]
async fn test_cat_file() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/test.txt".into()));
let result = Cat.execute(args, &mut ctx).await;
assert!(result.ok());
assert_eq!(&*result.text_out(), "hello world");
}
#[tokio::test]
async fn test_cat_nested() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/dir/nested.txt".into()));
let result = Cat.execute(args, &mut ctx).await;
assert!(result.ok());
assert_eq!(&*result.text_out(), "nested content");
}
#[tokio::test]
async fn test_cat_not_found() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/nonexistent.txt".into()));
let result = Cat.execute(args, &mut ctx).await;
assert!(!result.ok());
assert!(result.err.contains("not found") || result.err.contains("nonexistent"));
}
#[tokio::test]
async fn test_cat_no_arg_no_stdin() {
let mut ctx = make_ctx().await;
let args = ToolArgs::new();
let result = Cat.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().is_empty());
}
#[tokio::test]
async fn test_cat_from_stdin() {
let mut ctx = make_ctx().await;
ctx.set_stdin("hello from stdin".to_string());
let args = ToolArgs::new();
let result = Cat.execute(args, &mut ctx).await;
assert!(result.ok());
assert_eq!(&*result.text_out(), "hello from stdin");
}
#[tokio::test]
async fn test_cat_from_stdin_with_line_numbers() {
let mut ctx = make_ctx().await;
ctx.set_stdin("line1\nline2\nline3".to_string());
let mut args = ToolArgs::new();
args.flags.insert("n".to_string());
let result = Cat.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("1\tline1"));
assert!(result.text_out().contains("2\tline2"));
assert!(result.text_out().contains("3\tline3"));
}
#[tokio::test]
async fn test_cat_multiple_files() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/test.txt".into()));
args.positional.push(Value::String("/other.txt".into()));
let result = Cat.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("hello world"));
assert!(result.text_out().contains("other content"));
}
#[tokio::test]
async fn test_cat_n_line_numbers() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/lines.txt".into()));
args.flags.insert("n".to_string());
let result = Cat.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("1\tline1"));
assert!(result.text_out().contains("2\tline2"));
assert!(result.text_out().contains("3\tline3"));
}
#[tokio::test]
async fn test_cat_number_line_numbers() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/lines.txt".into()));
args.flags.insert("number".to_string());
let result = Cat.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("1\tline1"));
}
#[tokio::test]
async fn test_cat_n_multiple_files_continuous_numbering() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("/lines.txt".into()));
args.positional.push(Value::String("/test.txt".into()));
args.flags.insert("n".to_string());
let result = Cat.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("4\thello world"));
}
#[tokio::test]
async fn test_cat_glob() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("*.txt".into()));
let result = Cat.execute(args, &mut ctx).await;
assert!(result.ok());
assert!(result.text_out().contains("hello world"));
assert!(result.text_out().contains("other content"));
}
#[tokio::test]
async fn test_cat_glob_no_matches() {
let mut ctx = make_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("*.nonexistent".into()));
let result = Cat.execute(args, &mut ctx).await;
assert!(!result.ok());
}
}