Skip to main content

recursive/tools/
count_lines.rs

1//! `count_lines` tool: returns the number of lines in a text file.
2//!
3//! All paths are sandboxed to a workspace root, same as `ReadFile`.
4
5use async_trait::async_trait;
6use serde_json::{json, Value};
7use std::path::PathBuf;
8
9use super::{resolve_within_any, AccessTier, SharedSandboxRoots, Tool};
10use crate::error::{Error, Result};
11use crate::llm::ToolSpec;
12
13// ---------------------------------------------------------------------------
14// CountLines
15// ---------------------------------------------------------------------------
16
17#[derive(Debug, Clone)]
18pub struct CountLines {
19    pub root: PathBuf,
20    pub extra_roots: Vec<(PathBuf, AccessTier)>,
21    pub session_roots: Option<SharedSandboxRoots>,
22}
23
24impl CountLines {
25    pub fn new(root: impl Into<PathBuf>) -> Self {
26        Self {
27            root: root.into(),
28            extra_roots: Vec::new(),
29            session_roots: None,
30        }
31    }
32
33    /// Append additional allowed sandbox roots. See
34    /// [`crate::tools::fs::ReadFile::with_extra_roots`].
35    pub fn with_extra_roots(
36        mut self,
37        extra: impl IntoIterator<Item = (PathBuf, AccessTier)>,
38    ) -> Self {
39        self.extra_roots.extend(extra);
40        self
41    }
42
43    /// Attach the shared, session-mutable roots slot. See [`SharedSandboxRoots`].
44    pub fn with_session_roots(mut self, slot: SharedSandboxRoots) -> Self {
45        self.session_roots = Some(slot);
46        self
47    }
48
49    /// Convenience: attach the shared slot only when `Some`.
50    pub fn with_session_roots_opt(mut self, slot: Option<SharedSandboxRoots>) -> Self {
51        if let Some(s) = slot {
52            self.session_roots = Some(s);
53        }
54        self
55    }
56
57    fn all_roots(&self) -> Vec<(PathBuf, AccessTier)> {
58        let mut v: Vec<(PathBuf, AccessTier)> = Vec::with_capacity(self.extra_roots.len() + 1);
59        v.push((self.root.clone(), AccessTier::ReadWrite));
60        v.extend(self.extra_roots.iter().cloned());
61        if let Some(slot) = &self.session_roots {
62            if let Ok(roots) = slot.read() {
63                v.extend(roots.iter().cloned());
64            }
65        }
66        v
67    }
68}
69
70#[async_trait]
71impl Tool for CountLines {
72    fn spec(&self) -> ToolSpec {
73        ToolSpec {
74            name: "count_lines".into(),
75            description: "Return the number of lines in a text file inside the workspace.".into(),
76            parameters: json!({
77                "type": "object",
78                "properties": {
79                    "path": {
80                        "type": "string",
81                        "description": "Path relative to the workspace root"
82                    }
83                },
84                "required": ["path"]
85            }),
86        }
87    }
88
89    fn side_effect_class(&self) -> crate::tools::ToolSideEffect {
90        crate::tools::ToolSideEffect::ReadOnly
91    }
92
93    async fn execute(&self, args: Value) -> Result<String> {
94        let path = args["path"].as_str().ok_or_else(|| Error::BadToolArgs {
95            name: "count_lines".into(),
96            message: "missing `path`".into(),
97        })?;
98        let abs = resolve_within_any(&self.all_roots(), path, false)?;
99        let content = tokio::fs::read_to_string(&abs)
100            .await
101            .map_err(|e| Error::Tool {
102                name: "count_lines".into(),
103                call_id: None,
104                message: format!("{}: {e}", abs.display()),
105            })?;
106        let count = content.lines().count();
107        Ok(count.to_string())
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use tempfile::TempDir;
115
116    #[tokio::test]
117    async fn count_lines_happy_path() {
118        let tmp = TempDir::new().unwrap();
119        let contents = "line1\nline2\nline3\n";
120        std::fs::write(tmp.path().join("test.txt"), contents).unwrap();
121        let tool = CountLines::new(tmp.path());
122        let result = tool.execute(json!({"path": "test.txt"})).await.unwrap();
123        assert_eq!(result, "3");
124    }
125
126    #[tokio::test]
127    async fn count_lines_rejects_escape() {
128        let tmp = TempDir::new().unwrap();
129        let tool = CountLines::new(tmp.path());
130        let err = tool
131            .execute(json!({"path": "../etc/passwd"}))
132            .await
133            .unwrap_err();
134        assert!(matches!(err, Error::BadToolArgs { .. }));
135    }
136
137    #[tokio::test]
138    async fn count_lines_empty_file_returns_zero() {
139        // kills mutations that replace `content.lines().count()` with a non-zero value
140        let tmp = TempDir::new().unwrap();
141        std::fs::write(tmp.path().join("empty.txt"), "").unwrap();
142        let tool = CountLines::new(tmp.path());
143        let result = tool.execute(json!({"path": "empty.txt"})).await.unwrap();
144        assert_eq!(result, "0");
145    }
146
147    #[tokio::test]
148    async fn count_lines_no_trailing_newline() {
149        // kills mutations that change line counting semantics;
150        // `str::lines()` treats "a\nb" as 2 lines (no trailing newline does not add an empty line)
151        let tmp = TempDir::new().unwrap();
152        std::fs::write(tmp.path().join("no_nl.txt"), "a\nb").unwrap();
153        let tool = CountLines::new(tmp.path());
154        let result = tool.execute(json!({"path": "no_nl.txt"})).await.unwrap();
155        assert_eq!(result, "2");
156    }
157
158    #[tokio::test]
159    async fn count_lines_missing_path_argument_errors() {
160        // kills `ok_or_else(|| Error::BadToolArgs)` removal mutation
161        let tmp = TempDir::new().unwrap();
162        let tool = CountLines::new(tmp.path());
163        let res = tool.execute(json!({})).await;
164        assert!(
165            matches!(res, Err(Error::BadToolArgs { .. })),
166            "missing 'path' must return BadToolArgs"
167        );
168    }
169
170    #[tokio::test]
171    async fn count_lines_nonexistent_file_errors() {
172        // kills `map_err(|e| Error::Tool {...})` removal mutation
173        let tmp = TempDir::new().unwrap();
174        let tool = CountLines::new(tmp.path());
175        let res = tool.execute(json!({"path": "nonexistent.txt"})).await;
176        assert!(
177            matches!(res, Err(Error::Tool { .. })),
178            "nonexistent file must return Tool error"
179        );
180    }
181}