Skip to main content

recursive/tools/
count_lines.rs

1//! Count lines tool: returns the number of lines in a text file.
2
3use async_trait::async_trait;
4use serde_json::{json, Value};
5use std::path::PathBuf;
6
7use super::{resolve_within, Tool};
8use crate::error::{Error, Result};
9use crate::llm::ToolSpec;
10
11#[derive(Debug, Clone)]
12pub struct CountLines {
13    pub root: PathBuf,
14}
15
16impl CountLines {
17    pub fn new(root: impl Into<PathBuf>) -> Self {
18        Self { root: root.into() }
19    }
20}
21
22#[async_trait]
23impl Tool for CountLines {
24    fn spec(&self) -> ToolSpec {
25        ToolSpec {
26            name: "count_lines".into(),
27            description: "Count the number of lines in a text file.".into(),
28            parameters: json!({
29                "type": "object",
30                "properties": {
31                    "path": {"type": "string", "description": "Path relative to the workspace root"}
32                },
33                "required": ["path"]
34            }),
35        }
36    }
37
38    async fn execute(&self, args: Value) -> Result<String> {
39        let path = args["path"].as_str().ok_or_else(|| Error::BadToolArgs {
40            name: "count_lines".into(),
41            message: "missing `path`".into(),
42        })?;
43        let abs = resolve_within(&self.root, path)?;
44        let contents = tokio::fs::read_to_string(&abs)
45            .await
46            .map_err(|e| Error::Tool {
47                name: "count_lines".into(),
48                message: format!("{}: {e}", abs.display()),
49            })?;
50        let line_count = contents.lines().count();
51        Ok(line_count.to_string())
52    }
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58    use tempfile::TempDir;
59
60    #[tokio::test]
61    async fn count_lines_returns_correct_count() {
62        let tmp = TempDir::new().unwrap();
63        // Write a file with exactly 5 lines
64        let path = tmp.path().join("test.txt");
65        tokio::fs::write(&path, "line1\nline2\nline3\nline4\nline5")
66            .await
67            .unwrap();
68
69        let tool = CountLines::new(tmp.path());
70        let result = tool.execute(json!({"path": "test.txt"})).await.unwrap();
71
72        assert_eq!(result, "5");
73    }
74
75    #[tokio::test]
76    async fn count_lines_rejects_escape() {
77        let tmp = TempDir::new().unwrap();
78        let tool = CountLines::new(tmp.path());
79        let err = tool
80            .execute(json!({"path": "../etc/passwd"}))
81            .await
82            .unwrap_err();
83
84        assert!(matches!(err, Error::BadToolArgs { .. }));
85    }
86
87    #[tokio::test]
88    async fn count_lines_handles_empty_file() {
89        let tmp = TempDir::new().unwrap();
90        let path = tmp.path().join("empty.txt");
91        tokio::fs::write(&path, "").await.unwrap();
92
93        let tool = CountLines::new(tmp.path());
94        let result = tool.execute(json!({"path": "empty.txt"})).await.unwrap();
95
96        // An empty file has 0 lines (lines() returns an empty iterator)
97        assert_eq!(result, "0");
98    }
99
100    #[tokio::test]
101    async fn count_lines_handles_single_line_no_newline() {
102        let tmp = TempDir::new().unwrap();
103        let path = tmp.path().join("single.txt");
104        tokio::fs::write(&path, "just one line").await.unwrap();
105
106        let tool = CountLines::new(tmp.path());
107        let result = tool.execute(json!({"path": "single.txt"})).await.unwrap();
108
109        assert_eq!(result, "1");
110    }
111}