steer_tools/tools/
view.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::path::Path;
4use steer_macros::tool;
5use thiserror::Error;
6use tokio::fs::File;
7use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
8
9use crate::result::FileContentResult;
10use crate::{ExecutionContext, ToolError};
11
12#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
13pub struct ViewParams {
14    /// The absolute path to the file to read
15    pub file_path: String,
16    /// The line number to start reading from (1-indexed)
17    pub offset: Option<u64>,
18    /// The maximum number of lines to read
19    pub limit: Option<u64>,
20}
21
22#[derive(Error, Debug)]
23enum ViewError {
24    #[error("Failed to open file '{path}': {source}")]
25    FileOpen {
26        path: String,
27        #[source]
28        source: std::io::Error,
29    },
30    #[error("Failed to get file metadata for '{path}': {source}")]
31    Metadata {
32        path: String,
33        #[source]
34        source: std::io::Error,
35    },
36    #[error("File read cancelled")]
37    Cancelled,
38    #[error("Error reading file line by line: {source}")]
39    ReadLine {
40        #[source]
41        source: std::io::Error,
42    },
43    #[error("Error reading file: {source}")]
44    Read {
45        #[source]
46        source: std::io::Error,
47    },
48}
49
50const MAX_READ_BYTES: usize = 50 * 1024; // Limit read size to 50KB
51const MAX_LINE_LENGTH: usize = 2000; // Maximum characters per line before truncation
52
53tool! {
54    ViewTool {
55        params: ViewParams,
56        output: FileContentResult,
57        variant: FileContent,
58        description: format!(r#"Reads a file from the local filesystem. The file_path parameter must be an absolute path, not a relative path.
59By default, it reads up to 2000 lines starting from the beginning of the file. You can optionally specify a line offset and limit
60(especially handy for long files), but it's recommended to read the whole file by not providing these parameters.
61Any lines longer than {} characters will be truncated."#, MAX_LINE_LENGTH),
62        name: "read_file",
63        require_approval: false
64    }
65
66    async fn run(
67        _tool: &ViewTool,
68        params: ViewParams,
69        context: &ExecutionContext,
70    ) -> Result<FileContentResult, ToolError> {
71        if context.is_cancelled() {
72            return Err(ToolError::Cancelled(VIEW_TOOL_NAME.to_string()));
73        }
74
75        // Convert to absolute path relative to working directory
76        let abs_path = if Path::new(&params.file_path).is_absolute() {
77            params.file_path.clone()
78        } else {
79            context.working_directory.join(&params.file_path)
80                .to_string_lossy()
81                .to_string()
82        };
83
84        view_file_internal(
85            &abs_path,
86            params.offset.map(|v| v as usize),
87            params.limit.map(|v| v as usize),
88            context,
89        )
90        .await
91        .map_err(|e| ToolError::io(VIEW_TOOL_NAME, e.to_string()))
92    }
93}
94
95async fn view_file_internal(
96    file_path: &str,
97    offset: Option<usize>,
98    limit: Option<usize>,
99    context: &ExecutionContext,
100) -> Result<FileContentResult, ViewError> {
101    let mut file = File::open(file_path)
102        .await
103        .map_err(|e| ViewError::FileOpen {
104            path: file_path.to_string(),
105            source: e,
106        })?;
107
108    let file_size = file
109        .metadata()
110        .await
111        .map_err(|e| ViewError::Metadata {
112            path: file_path.to_string(),
113            source: e,
114        })?
115        .len();
116
117    let mut buffer = Vec::new();
118    let start_line = offset.unwrap_or(1).max(1); // 1-indexed
119    let line_limit = limit;
120    #[allow(unused_assignments)]
121    let mut total_lines = 0;
122    let mut truncated = false;
123
124    if start_line > 1 || line_limit.is_some() {
125        // Read line by line if offset or limit is specified
126        let mut reader = BufReader::new(file);
127        let mut current_line_num = 1;
128        let mut lines_read = 0;
129        let mut lines = Vec::new();
130
131        loop {
132            // Check for cancellation in the loop
133            if context.is_cancelled() {
134                return Err(ViewError::Cancelled);
135            }
136
137            let mut line = String::new();
138            match reader.read_line(&mut line).await {
139                Ok(0) => break, // EOF
140                Ok(_) => {
141                    if current_line_num >= start_line {
142                        // Truncate long lines
143                        if line.len() > MAX_LINE_LENGTH {
144                            line.truncate(MAX_LINE_LENGTH);
145                            line.push_str("... [line truncated]");
146                        }
147                        lines.push(line.trim_end().to_string()); // Store line
148                        lines_read += 1;
149                        if line_limit.is_some_and(|l| lines_read >= l) {
150                            truncated = true;
151                            break; // Reached limit
152                        }
153                    }
154                    current_line_num += 1;
155                }
156                Err(e) => {
157                    return Err(ViewError::ReadLine { source: e });
158                }
159            }
160        }
161
162        // Add line numbers
163        total_lines = lines.len();
164        let numbered_lines: Vec<String> = lines
165            .into_iter()
166            .enumerate()
167            .map(|(i, line)| format!("{:5}\t{}", start_line + i, line))
168            .collect();
169
170        buffer = numbered_lines.join("\n").into_bytes();
171    } else {
172        // Read the whole file (up to MAX_READ_BYTES)
173        let read_size = std::cmp::min(file_size as usize, MAX_READ_BYTES);
174        buffer.resize(read_size, 0);
175        let mut bytes_read = 0;
176        while bytes_read < read_size {
177            // Check for cancellation in the loop
178            if context.is_cancelled() {
179                return Err(ViewError::Cancelled);
180            }
181            let n = file
182                .read(&mut buffer[bytes_read..])
183                .await
184                .map_err(|e| ViewError::Read { source: e })?;
185            if n == 0 {
186                break; // EOF
187            }
188            bytes_read += n;
189        }
190        buffer.truncate(bytes_read);
191
192        // Convert to string and add line numbers
193        let content = String::from_utf8_lossy(&buffer);
194        let lines: Vec<&str> = content.lines().collect();
195        total_lines = lines.len();
196        truncated = file_size as usize > MAX_READ_BYTES;
197        let numbered_lines: Vec<String> = lines
198            .into_iter()
199            .enumerate()
200            .map(|(i, line)| {
201                // Truncate long lines
202                let truncated_line = if line.len() > MAX_LINE_LENGTH {
203                    format!("{}... [line truncated]", &line[..MAX_LINE_LENGTH])
204                } else {
205                    line.to_string()
206                };
207                format!("{:5}\t{}", i + 1, truncated_line)
208            })
209            .collect();
210
211        buffer = numbered_lines.join("\n").into_bytes();
212    }
213
214    // Return the final content
215    let content = String::from_utf8_lossy(&buffer).to_string();
216    Ok(FileContentResult {
217        content,
218        file_path: file_path.to_string(),
219        line_count: total_lines,
220        truncated,
221    })
222}