Skip to main content

robit_agent/tool/
read.rs

1//! `read` tool — reads file contents with line numbers.
2
3use async_trait::async_trait;
4use serde::Deserialize;
5use serde_json::Value;
6
7use super::{resolve_path, Tool, ToolContext, ToolResult};
8use crate::error::Result;
9
10pub struct ReadTool {
11    /// Max output lines before truncation.
12    max_output_lines: usize,
13    /// Max output bytes before truncation.
14    max_output_bytes: usize,
15}
16
17#[derive(Debug, Deserialize)]
18struct ReadArgs {
19    file_path: String,
20    #[serde(default)]
21    offset: Option<usize>,
22    #[serde(default)]
23    limit: Option<usize>,
24}
25
26impl ReadTool {
27    pub fn new(max_output_lines: usize, max_output_bytes: usize) -> Self {
28        Self {
29            max_output_lines,
30            max_output_bytes,
31        }
32    }
33}
34
35#[async_trait]
36impl Tool for ReadTool {
37    fn name(&self) -> &str {
38        "read"
39    }
40
41    fn description(&self) -> &str {
42        "Read file contents. Supports text files. Large files can be read in segments using offset/limit. Output includes line numbers."
43    }
44
45    fn parameters_schema(&self) -> Value {
46        serde_json::json!({
47            "type": "object",
48            "properties": {
49                "file_path": {
50                    "type": "string",
51                    "description": "File path (relative or absolute)"
52                },
53                "offset": {
54                    "type": "integer",
55                    "description": "Starting line number (0-based, default 0)"
56                },
57                "limit": {
58                    "type": "integer",
59                    "description": "Max number of lines to read (default: read all)"
60                }
61            },
62            "required": ["file_path"]
63        })
64    }
65
66    fn requires_confirmation(&self) -> bool {
67        false
68    }
69
70    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<ToolResult> {
71        let parsed: ReadArgs = match serde_json::from_value(args) {
72            Ok(a) => a,
73            Err(e) => return Ok(ToolResult::error(format!("Argument parsing failed: {}", e))),
74        };
75
76        // Resolve file path
77        let path = resolve_path(&parsed.file_path, &ctx.working_dir);
78
79        // Check if file exists
80        if !path.exists() {
81            return Ok(ToolResult::error(format!("File not found: {}", path.display())));
82        }
83
84        if path.is_dir() {
85            return Ok(ToolResult::error(format!(
86                "'{}' is a directory, not a file",
87                path.display()
88            )));
89        }
90
91        // Read file content
92        let content = match tokio::fs::read_to_string(&path).await {
93            Ok(c) => c,
94            Err(e) => {
95                return Ok(ToolResult::error(format!(
96                    "Failed to read file '{}': {}",
97                    path.display(),
98                    e
99                )));
100            }
101        };
102
103        let all_lines: Vec<&str> = content.lines().collect();
104        let total_lines = all_lines.len();
105        let offset = parsed.offset.unwrap_or(0);
106        let limit = parsed.limit.unwrap_or(total_lines);
107
108        // Validate offset
109        if offset > total_lines {
110            return Ok(ToolResult::error(format!(
111                "offset {} is out of range, file has {} lines",
112                offset, total_lines
113            )));
114        }
115
116        let end = (offset + limit).min(total_lines);
117        let selected_lines = &all_lines[offset..end];
118
119        // Build output with line numbers
120        let mut output = String::new();
121        let mut byte_count = 0;
122
123        for (i, line) in selected_lines.iter().enumerate() {
124            let line_num = offset + i + 1; // 1-based line numbers
125            let formatted = format!("{:>6}\t{}\n", line_num, line);
126
127            // Check byte limit
128            if byte_count + formatted.len() > self.max_output_bytes {
129                output.push_str(&format!(
130                    "\n... (Output truncated, byte limit of {} bytes reached)\n",
131                    self.max_output_bytes
132                ));
133                return Ok(ToolResult::success(output));
134            }
135
136            // Check line limit
137            if i >= self.max_output_lines {
138                output.push_str(&format!(
139                    "\n... (Output truncated, {} lines total, showing first {}. Use offset/limit to read more)\n",
140                    total_lines, self.max_output_lines
141                ));
142                return Ok(ToolResult::success(output));
143            }
144
145            byte_count += formatted.len();
146            output.push_str(&formatted);
147        }
148
149        // Add summary if only part of file was shown
150        if offset > 0 || end < total_lines {
151            output.push_str(&format!(
152                "\n(Showing lines {}-{} of {})",
153                offset + 1,
154                end,
155                total_lines
156            ));
157        }
158
159        Ok(ToolResult::success(output))
160    }
161}