recursive/tools/
count_lines.rs1use 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 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 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}