1use kiss_agent::tool::{AgentTool, ToolResult, ToolUpdateSink};
4use kiss_agent::tools::path::resolve;
5use kiss_agent::tools::truncate::{DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, truncate_head};
6use serde_json::{Value, json};
7use std::path::PathBuf;
8use tokio_util::sync::CancellationToken;
9
10const DEFAULT_LIMIT: usize = 500;
11
12pub struct LsTool {
13 pub cwd: PathBuf,
14}
15
16#[async_trait::async_trait]
17impl AgentTool for LsTool {
18 fn name(&self) -> &str {
19 "ls"
20 }
21
22 fn description(&self) -> String {
23 "List directory contents. Directories are listed first with a trailing slash.".to_string()
24 }
25
26 fn parameters(&self) -> Value {
27 json!({
28 "type": "object",
29 "properties": {
30 "path": {"type": "string", "description": "Directory to list (default: current directory)"},
31 "limit": {"type": "number", "description": "Maximum number of entries to return (default: 500)"},
32 },
33 })
34 }
35
36 async fn execute(
37 &self,
38 _id: &str,
39 args: Value,
40 _cancel: CancellationToken,
41 _on_update: Option<ToolUpdateSink>,
42 ) -> anyhow::Result<ToolResult> {
43 let path = resolve(&self.cwd, args["path"].as_str().unwrap_or("."));
44 let limit = args["limit"]
45 .as_f64()
46 .map(|v| v as usize)
47 .unwrap_or(DEFAULT_LIMIT);
48 if !path.exists() {
49 anyhow::bail!("Path not found: {}", path.display());
50 }
51 if !path.is_dir() {
52 anyhow::bail!("Not a directory: {}", path.display());
53 }
54 let mut dirs: Vec<String> = Vec::new();
55 let mut files: Vec<String> = Vec::new();
56 let mut read = tokio::fs::read_dir(&path).await?;
57 while let Some(entry) = read.next_entry().await? {
58 let name = entry.file_name().to_string_lossy().into_owned();
59 if entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false) {
60 dirs.push(format!("{name}/"));
61 } else {
62 files.push(name);
63 }
64 }
65 dirs.sort();
66 files.sort();
67 let mut entries: Vec<String> = dirs;
68 entries.extend(files);
69 let total = entries.len();
70 let limit_reached = total > limit;
71 entries.truncate(limit);
72
73 if entries.is_empty() {
74 return Ok(ToolResult::text("(empty directory)"));
75 }
76 let joined = entries.join("\n");
77 let truncation = truncate_head(&joined, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
78 let mut output = truncation.content.clone();
79 if limit_reached {
80 output.push_str(&format!(
81 "\n\n[Entry limit of {limit} reached ({total} total).]"
82 ));
83 }
84 Ok(ToolResult {
85 content: vec![kiss_ai::ContentBlock::text(output)],
86 details: json!({"entryLimitReached": if limit_reached { Some(limit) } else { None }}),
87 ..Default::default()
88 })
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 #[tokio::test]
97 async fn dirs_first_with_slash() {
98 let dir = tempfile::tempdir().unwrap();
99 std::fs::create_dir_all(dir.path().join("zdir")).unwrap();
100 std::fs::write(dir.path().join("afile.txt"), "").unwrap();
101 let tool = LsTool {
102 cwd: dir.path().to_path_buf(),
103 };
104 let r = tool
105 .execute("1", json!({}), CancellationToken::new(), None)
106 .await
107 .unwrap();
108 assert_eq!(r.output_text(), "zdir/\nafile.txt");
109 }
110}