recursive/tools/
count_lines.rs1use 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#[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 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 pub fn with_session_roots(mut self, slot: SharedSandboxRoots) -> Self {
45 self.session_roots = Some(slot);
46 self
47 }
48
49 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 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 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 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 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}