Skip to main content

kiss_coding/tools/
ls.rs

1//! Ls tool: directory listing, directories first with a trailing slash.
2
3use 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}