1use std::sync::Arc;
9
10use async_trait::async_trait;
11use serde::{Deserialize, Serialize};
12use serde_json::{json, Value};
13use tokio::fs;
14
15use super::context::{ToolContext, ToolEvent};
16use super::{FileTool, ToolErrorCode, ToolOutput};
17use crate::error::NikaError;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ReadParams {
26 pub file_path: String,
28
29 #[serde(default)]
31 pub offset: Option<usize>,
32
33 #[serde(default)]
35 pub limit: Option<usize>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct ReadResult {
41 pub content: String,
43
44 pub total_lines: usize,
46
47 pub lines_returned: usize,
49
50 pub truncated: bool,
52}
53
54pub struct ReadTool {
67 ctx: Arc<ToolContext>,
68}
69
70impl ReadTool {
71 pub const DEFAULT_LIMIT: usize = 2000;
73
74 pub const MAX_LINE_LENGTH: usize = 2000;
76
77 pub fn new(ctx: Arc<ToolContext>) -> Self {
79 Self { ctx }
80 }
81
82 pub async fn execute(&self, params: ReadParams) -> Result<ReadResult, NikaError> {
84 let path = self.ctx.validate_path(¶ms.file_path)?;
86
87 if self.ctx.permission_mode() == super::context::PermissionMode::Deny {
89 return Err(NikaError::ToolError {
90 code: ToolErrorCode::PermissionDenied.code(),
91 message: "Read operations are denied in current permission mode".to_string(),
92 });
93 }
94
95 if !path.exists() {
97 return Err(NikaError::ToolError {
98 code: ToolErrorCode::FileNotFound.code(),
99 message: format!("File not found: {}", params.file_path),
100 });
101 }
102
103 let content = fs::read_to_string(&path)
105 .await
106 .map_err(|e| NikaError::ToolError {
107 code: ToolErrorCode::ReadFailed.code(),
108 message: format!("Failed to read file: {}", e),
109 })?;
110
111 let all_lines: Vec<&str> = content.lines().collect();
113 let total_lines = all_lines.len();
114
115 let offset = params.offset.unwrap_or(1).saturating_sub(1);
117 let limit = params.limit.unwrap_or(Self::DEFAULT_LIMIT);
118
119 let selected_lines: Vec<&str> = all_lines.into_iter().skip(offset).take(limit).collect();
120
121 let lines_returned = selected_lines.len();
122 let truncated = offset + lines_returned < total_lines;
123
124 let formatted = selected_lines
127 .iter()
128 .enumerate()
129 .map(|(i, line)| {
130 let line_num = offset + i + 1;
131 let truncated_line = if line.len() > Self::MAX_LINE_LENGTH {
132 let mut end = Self::MAX_LINE_LENGTH;
134 while end > 0 && !line.is_char_boundary(end) {
135 end -= 1;
136 }
137 format!("{}...", &line[..end])
138 } else {
139 line.to_string()
140 };
141 format!("{:>6}\t{}", line_num, truncated_line)
142 })
143 .collect::<Vec<_>>()
144 .join("\n");
145
146 self.ctx.mark_as_read(&path);
148
149 self.ctx
151 .emit(ToolEvent::FileRead {
152 path: params.file_path,
153 lines: lines_returned,
154 truncated,
155 })
156 .await;
157
158 Ok(ReadResult {
159 content: formatted,
160 total_lines,
161 lines_returned,
162 truncated,
163 })
164 }
165}
166
167#[async_trait]
168impl FileTool for ReadTool {
169 fn name(&self) -> &'static str {
170 "read"
171 }
172
173 fn description(&self) -> &'static str {
174 "Read a file from the filesystem. Returns content with line numbers. \
175 Use offset and limit for large files. Must use absolute paths within \
176 the working directory."
177 }
178
179 fn parameters_schema(&self) -> Value {
180 json!({
181 "type": "object",
182 "properties": {
183 "file_path": {
184 "type": "string",
185 "description": "Absolute path to the file to read"
186 },
187 "offset": {
188 "type": "integer",
189 "description": "Line number to start reading from (1-indexed)",
190 "minimum": 1
191 },
192 "limit": {
193 "type": "integer",
194 "description": "Maximum number of lines to read (default: 2000)",
195 "minimum": 1,
196 "maximum": 10000
197 }
198 },
199 "required": ["file_path", "offset", "limit"],
200 "additionalProperties": false
201 })
202 }
203
204 async fn call(&self, params: Value) -> Result<ToolOutput, NikaError> {
205 let params: ReadParams =
206 serde_json::from_value(params).map_err(|e| NikaError::ToolError {
207 code: ToolErrorCode::ReadFailed.code(),
208 message: format!("Invalid parameters: {}", e),
209 })?;
210
211 let result = self.execute(params).await?;
212
213 Ok(ToolOutput::success_with_data(
214 result.content.clone(),
215 serde_json::to_value(&result).unwrap_or_default(),
216 ))
217 }
218}
219
220#[cfg(test)]
225mod tests {
226 use super::*;
227 use crate::tools::context::testing::{create_test_file, setup_test};
228
229 #[tokio::test]
230 async fn test_read_simple_file() {
231 let (temp_dir, ctx) = setup_test().await;
232 let path = create_test_file(&temp_dir, "test.txt", "line 1\nline 2\nline 3").await;
233 let file_path = path.to_string_lossy().to_string();
234
235 let tool = ReadTool::new(ctx);
236 let result = tool
237 .execute(ReadParams {
238 file_path,
239 offset: None,
240 limit: None,
241 })
242 .await
243 .unwrap();
244
245 assert_eq!(result.total_lines, 3);
246 assert_eq!(result.lines_returned, 3);
247 assert!(!result.truncated);
248 assert!(result.content.contains("line 1"));
249 assert!(result.content.contains("line 3"));
250 }
251
252 #[tokio::test]
253 async fn test_read_with_offset() {
254 let (temp_dir, ctx) = setup_test().await;
255 let content = (1..=10)
256 .map(|i| format!("line {}", i))
257 .collect::<Vec<_>>()
258 .join("\n");
259 let path = create_test_file(&temp_dir, "test.txt", &content).await;
260 let file_path = path.to_string_lossy().to_string();
261
262 let tool = ReadTool::new(ctx);
263 let result = tool
264 .execute(ReadParams {
265 file_path,
266 offset: Some(5),
267 limit: Some(3),
268 })
269 .await
270 .unwrap();
271
272 assert_eq!(result.total_lines, 10);
273 assert_eq!(result.lines_returned, 3);
274 assert!(result.truncated);
275 assert!(result.content.contains("line 5"));
276 assert!(result.content.contains("line 7"));
277 assert!(!result.content.contains("line 4"));
278 }
279
280 #[tokio::test]
281 async fn test_read_line_numbers_format() {
282 let (temp_dir, ctx) = setup_test().await;
283 let path = create_test_file(&temp_dir, "test.txt", "hello\nworld").await;
284 let file_path = path.to_string_lossy().to_string();
285
286 let tool = ReadTool::new(ctx);
287 let result = tool
288 .execute(ReadParams {
289 file_path,
290 offset: None,
291 limit: None,
292 })
293 .await
294 .unwrap();
295
296 assert!(result.content.contains(" 1\thello"));
298 assert!(result.content.contains(" 2\tworld"));
299 }
300
301 #[tokio::test]
302 async fn test_read_marks_file_as_read() {
303 let (temp_dir, ctx) = setup_test().await;
304 let path = create_test_file(&temp_dir, "test.txt", "content").await;
305 let file_path = path.to_string_lossy().to_string();
306
307 assert!(!ctx.was_read(&path));
308
309 let tool = ReadTool::new(ctx.clone());
310 tool.execute(ReadParams {
311 file_path,
312 offset: None,
313 limit: None,
314 })
315 .await
316 .unwrap();
317
318 assert!(ctx.was_read(&path));
319 }
320
321 #[tokio::test]
322 async fn test_read_file_not_found() {
323 let (temp_dir, ctx) = setup_test().await;
324 let file_path = temp_dir
325 .path()
326 .join("nonexistent.txt")
327 .to_string_lossy()
328 .to_string();
329
330 let tool = ReadTool::new(ctx);
331 let result = tool
332 .execute(ReadParams {
333 file_path,
334 offset: None,
335 limit: None,
336 })
337 .await;
338
339 assert!(result.is_err());
340 assert!(result.unwrap_err().to_string().contains("not found"));
341 }
342
343 #[tokio::test]
344 async fn test_read_outside_working_dir() {
345 let (_temp_dir, ctx) = setup_test().await;
346
347 let tool = ReadTool::new(ctx);
348 let result = tool
349 .execute(ReadParams {
350 file_path: "/etc/passwd".to_string(),
351 offset: None,
352 limit: None,
353 })
354 .await;
355
356 assert!(result.is_err());
357 assert!(result.unwrap_err().to_string().contains("outside"));
358 }
359
360 #[tokio::test]
361 async fn test_read_relative_path_resolved() {
362 let (temp_dir, ctx) = setup_test().await;
363
364 let file_path = temp_dir.path().join("relative.txt");
366 tokio::fs::write(&file_path, "relative content")
367 .await
368 .unwrap();
369
370 let tool = ReadTool::new(ctx);
371 let result = tool
372 .execute(ReadParams {
373 file_path: "relative.txt".to_string(),
374 offset: None,
375 limit: None,
376 })
377 .await;
378
379 assert!(
380 result.is_ok(),
381 "relative path should resolve: {:?}",
382 result.err()
383 );
384 assert!(result.unwrap().content.contains("relative content"));
385 }
386
387 #[tokio::test]
388 async fn test_file_tool_trait() {
389 let (temp_dir, ctx) = setup_test().await;
390 let path = create_test_file(&temp_dir, "test.txt", "content").await;
391 let file_path = path.to_string_lossy().to_string();
392
393 let tool = ReadTool::new(ctx);
394
395 assert_eq!(tool.name(), "read");
396 assert!(tool.description().contains("Read a file"));
397
398 let schema = tool.parameters_schema();
399 assert!(schema["properties"]["file_path"].is_object());
400
401 let result = tool.call(json!({ "file_path": file_path })).await.unwrap();
402
403 assert!(!result.is_error);
404 assert!(result.content.contains("content"));
405 }
406}