Skip to main content

capo_agent/tools/
ls.rs

1#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
2
3use std::future::Future;
4use std::path::PathBuf;
5use std::pin::Pin;
6use std::sync::Arc;
7
8use motosan_agent_tool::{Tool, ToolContext, ToolDef, ToolResult};
9use serde_json::{json, Value};
10
11use crate::tools::ToolCtx;
12
13pub struct LsTool {
14    ctx: Arc<ToolCtx>,
15}
16
17impl LsTool {
18    pub fn new(ctx: Arc<ToolCtx>) -> Self {
19        Self { ctx }
20    }
21}
22
23impl Tool for LsTool {
24    fn def(&self) -> ToolDef {
25        ToolDef {
26            name: "ls".to_string(),
27            description: "List the immediate entries of a directory (non-recursive).".to_string(),
28            input_schema: json!({
29                "type": "object",
30                "properties": {
31                    "path": { "type": "string", "description": "Directory path (absolute or cwd-relative). Defaults to cwd." }
32                }
33            }),
34        }
35    }
36
37    fn call(
38        &self,
39        args: Value,
40        _ctx: &ToolContext,
41    ) -> Pin<Box<dyn Future<Output = ToolResult> + Send + '_>> {
42        let ctx = Arc::clone(&self.ctx);
43        Box::pin(async move {
44            let rel = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
45            let abs = resolve_in_cwd(&ctx.cwd, rel);
46            if !abs.starts_with(&ctx.cwd) {
47                return ToolResult::error(format!(
48                    "path {} is outside the working directory",
49                    abs.display()
50                ));
51            }
52            let mut entries = match tokio::fs::read_dir(&abs).await {
53                Ok(rd) => rd,
54                Err(e) => {
55                    return ToolResult::error(format!("failed to list {}: {e}", abs.display()))
56                }
57            };
58            let mut lines: Vec<String> = Vec::new();
59            loop {
60                match entries.next_entry().await {
61                    Ok(Some(entry)) => {
62                        let name = entry.file_name().to_string_lossy().to_string();
63                        let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
64                        lines.push(if is_dir { format!("{name}/") } else { name });
65                    }
66                    Ok(None) => break,
67                    Err(e) => return ToolResult::error(format!("read_dir error: {e}")),
68                }
69            }
70            lines.sort();
71            ToolResult::text(lines.join("\n"))
72        })
73    }
74}
75
76/// Join `rel` onto `cwd` and lexically normalize `.`/`..` so an escaping
77/// path can be detected with `starts_with`. Does not touch the filesystem.
78pub(crate) fn resolve_in_cwd(cwd: &std::path::Path, rel: &str) -> PathBuf {
79    let joined = if std::path::Path::new(rel).is_absolute() {
80        PathBuf::from(rel)
81    } else {
82        cwd.join(rel)
83    };
84    let mut out = PathBuf::new();
85    for comp in joined.components() {
86        use std::path::Component::*;
87        match comp {
88            ParentDir => {
89                out.pop();
90            }
91            CurDir => {}
92            other => out.push(other.as_os_str()),
93        }
94    }
95    out
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::permissions::NoOpPermissionGate;
102    use tempfile::tempdir;
103    use tokio::sync::mpsc;
104
105    fn test_ctx(cwd: &std::path::Path) -> Arc<ToolCtx> {
106        let (tx, _rx) = mpsc::channel(8);
107        Arc::new(ToolCtx::new(cwd, Arc::new(NoOpPermissionGate), tx))
108    }
109
110    #[tokio::test]
111    async fn lists_directory_entries_sorted_with_dir_marker() {
112        let dir = tempdir().expect("tempdir");
113        tokio::fs::write(dir.path().join("b.txt"), "x")
114            .await
115            .unwrap();
116        tokio::fs::write(dir.path().join("a.txt"), "x")
117            .await
118            .unwrap();
119        tokio::fs::create_dir(dir.path().join("sub")).await.unwrap();
120
121        let tool = LsTool::new(test_ctx(dir.path()));
122        let result = tool.call(json!({}), &ToolContext::default()).await;
123        let text = result.as_text().unwrap_or_default();
124        assert_eq!(text, "a.txt\nb.txt\nsub/");
125    }
126
127    #[tokio::test]
128    async fn rejects_path_outside_cwd() {
129        let dir = tempdir().expect("tempdir");
130        let tool = LsTool::new(test_ctx(dir.path()));
131        let result = tool
132            .call(json!({ "path": "../.." }), &ToolContext::default())
133            .await;
134        assert!(result.is_error, "expected escape rejection");
135    }
136
137    #[tokio::test]
138    async fn errors_on_missing_directory() {
139        let dir = tempdir().expect("tempdir");
140        let tool = LsTool::new(test_ctx(dir.path()));
141        let result = tool
142            .call(json!({ "path": "nope" }), &ToolContext::default())
143            .await;
144        assert!(result.is_error);
145    }
146}