Skip to main content

nika_engine/tools/
read.rs

1//! Read Tool - Read files with line numbers
2//!
3//! Provides Claude Code-like file reading with:
4//! - Line number prefix (cat -n style)
5//! - Offset and limit for large files
6//! - Automatic read tracking for edit validation
7
8use 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// ═══════════════════════════════════════════════════════════════════════════
20// PARAMETERS & RESULT
21// ═══════════════════════════════════════════════════════════════════════════
22
23/// Parameters for the Read tool
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct ReadParams {
26    /// Absolute path to the file to read
27    pub file_path: String,
28
29    /// Line offset (1-indexed, skip first N-1 lines)
30    #[serde(default)]
31    pub offset: Option<usize>,
32
33    /// Maximum lines to read (default: 2000)
34    #[serde(default)]
35    pub limit: Option<usize>,
36}
37
38/// Result from reading a file
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct ReadResult {
41    /// File content with line numbers
42    pub content: String,
43
44    /// Total lines in file
45    pub total_lines: usize,
46
47    /// Lines actually returned
48    pub lines_returned: usize,
49
50    /// Whether output was truncated
51    pub truncated: bool,
52}
53
54// ═══════════════════════════════════════════════════════════════════════════
55// READ TOOL
56// ═══════════════════════════════════════════════════════════════════════════
57
58/// Read tool for reading files with line numbers
59///
60/// # Features
61///
62/// - Line numbers in output (cat -n style)
63/// - Offset/limit for large files
64/// - Tracks reads for edit validation
65/// - Max line length truncation (2000 chars)
66pub struct ReadTool {
67    ctx: Arc<ToolContext>,
68}
69
70impl ReadTool {
71    /// Default maximum lines to read
72    pub const DEFAULT_LIMIT: usize = 2000;
73
74    /// Maximum characters per line before truncation
75    pub const MAX_LINE_LENGTH: usize = 2000;
76
77    /// Create a new Read tool
78    pub fn new(ctx: Arc<ToolContext>) -> Self {
79        Self { ctx }
80    }
81
82    /// Execute the read operation
83    pub async fn execute(&self, params: ReadParams) -> Result<ReadResult, NikaError> {
84        // Validate path
85        let path = self.ctx.validate_path(&params.file_path)?;
86
87        // Check permission (reads are usually allowed, but respect Deny mode)
88        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        // Check file exists
96        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        // Read file content
104        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        // Process lines
112        let all_lines: Vec<&str> = content.lines().collect();
113        let total_lines = all_lines.len();
114
115        // Apply offset (1-indexed)
116        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        // Format with line numbers (cat -n style)
125        // Format: "    N\tline content"
126        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                    // Find a valid UTF-8 char boundary at or before MAX_LINE_LENGTH
133                    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        // Mark as read for edit validation
147        self.ctx.mark_as_read(&path);
148
149        // Emit event
150        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// ═══════════════════════════════════════════════════════════════════════════
221// TESTS
222// ═══════════════════════════════════════════════════════════════════════════
223
224#[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        // Check line number format (right-aligned with tab)
297        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        // Create a file at a relative path within the working dir
365        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}