Skip to main content

xcodeai/tools/
file_read.rs

1use crate::tools::{Tool, ToolContext, ToolResult};
2use anyhow::Result;
3use async_trait::async_trait;
4use std::path::{Path, PathBuf};
5
6pub struct FileReadTool;
7
8#[async_trait]
9impl Tool for FileReadTool {
10    fn name(&self) -> &str {
11        "file_read"
12    }
13
14    fn description(&self) -> &str {
15        "Read a file and return its contents with line numbers. Supports offset and limit parameters."
16    }
17
18    fn parameters_schema(&self) -> serde_json::Value {
19        serde_json::json!({
20            "type": "object",
21            "properties": {
22                "path": {
23                    "type": "string",
24                    "description": "Path to the file to read (relative to working_dir or absolute)"
25                },
26                "offset": {
27                    "type": "integer",
28                    "description": "Line number to start from (1-indexed, default: 1)"
29                },
30                "limit": {
31                    "type": "integer",
32                    "description": "Maximum number of lines to return (default: all)"
33                }
34            },
35            "required": ["path"]
36        })
37    }
38
39    async fn execute(&self, args: serde_json::Value, ctx: &ToolContext) -> Result<ToolResult> {
40        let path_str = match args["path"].as_str() {
41            Some(p) => p.to_string(),
42            None => {
43                return Ok(ToolResult {
44                    output: "Error: 'path' parameter is required".to_string(),
45                    is_error: true,
46                });
47            }
48        };
49
50        let path = resolve_path(&path_str, &ctx.working_dir);
51
52        let content = match std::fs::read_to_string(&path) {
53            Ok(c) => c,
54            Err(e) => {
55                return Ok(ToolResult {
56                    output: format!("Error: failed to read file '{}': {}", path_str, e),
57                    is_error: true,
58                });
59            }
60        };
61
62        let lines: Vec<&str> = content.lines().collect();
63        let total = lines.len();
64
65        let offset = args["offset"].as_u64().unwrap_or(1).saturating_sub(1) as usize;
66        let limit = args["limit"].as_u64().map(|l| l as usize).unwrap_or(total);
67
68        let end = (offset + limit).min(total);
69        let selected = &lines[offset.min(total)..end];
70
71        let output = selected
72            .iter()
73            .enumerate()
74            .map(|(i, line)| format!("{}: {}", offset + i + 1, line))
75            .collect::<Vec<_>>()
76            .join("\n");
77
78        // In compact mode, cap the output to 50 lines to save tokens.
79        // The agent can always call file_read again with offset/limit to get more.
80        let output = if ctx.compact_mode && selected.len() > 50 {
81            // Take only the first 50 lines of the selected slice.
82            let capped = selected[..50]
83                .iter()
84                .enumerate()
85                .map(|(i, line)| format!("{}: {}", offset + i + 1, line))
86                .collect::<Vec<_>>()
87                .join("\n");
88            format!(
89                "{}\n[compact: showing first 50 of {} lines — use offset/limit to read more]",
90                capped,
91                selected.len()
92            )
93        } else {
94            output
95        };
96
97        Ok(ToolResult {
98            output,
99            is_error: false,
100        })
101    }
102}
103
104fn resolve_path(path_str: &str, working_dir: &Path) -> PathBuf {
105    let p = PathBuf::from(path_str);
106    if p.is_absolute() {
107        p
108    } else {
109        working_dir.join(p)
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use std::io::Write;
117    use tempfile::NamedTempFile;
118
119    fn make_ctx(dir: &std::path::Path) -> ToolContext {
120        ToolContext {
121            working_dir: dir.to_path_buf(),
122            sandbox_enabled: false,
123            io: std::sync::Arc::new(crate::io::NullIO),
124            compact_mode: false,
125            lsp_client: std::sync::Arc::new(tokio::sync::Mutex::new(None)),
126            mcp_client: None,
127            nesting_depth: 0,
128            llm: std::sync::Arc::new(crate::llm::NullLlmProvider),
129            tools: std::sync::Arc::new(crate::tools::ToolRegistry::new()),
130        }
131    }
132    #[tokio::test]
133    async fn test_file_read_existing_file() {
134        let mut f = NamedTempFile::new().unwrap();
135        writeln!(f, "line one").unwrap();
136        writeln!(f, "line two").unwrap();
137        writeln!(f, "line three").unwrap();
138        let path = f.path().to_string_lossy().to_string();
139        let ctx = make_ctx(f.path().parent().unwrap());
140
141        let tool = FileReadTool;
142        let result = tool
143            .execute(serde_json::json!({ "path": path }), &ctx)
144            .await
145            .unwrap();
146
147        assert!(!result.is_error);
148        assert!(result.output.contains("1: line one"));
149        assert!(result.output.contains("2: line two"));
150        assert!(result.output.contains("3: line three"));
151    }
152
153    #[tokio::test]
154    async fn test_file_read_missing_file() {
155        let ctx = make_ctx(std::path::Path::new("/tmp"));
156        let tool = FileReadTool;
157        let result = tool
158            .execute(
159                serde_json::json!({ "path": "/tmp/this_file_does_not_exist_xcode_test.txt" }),
160                &ctx,
161            )
162            .await
163            .unwrap();
164
165        assert!(result.is_error);
166        assert!(result.output.contains("Error"));
167    }
168
169    #[tokio::test]
170    async fn test_file_read_with_offset_limit() {
171        let mut f = NamedTempFile::new().unwrap();
172        for i in 1..=10 {
173            writeln!(f, "line {}", i).unwrap();
174        }
175        let path = f.path().to_string_lossy().to_string();
176        let ctx = make_ctx(f.path().parent().unwrap());
177
178        let tool = FileReadTool;
179        let result = tool
180            .execute(
181                serde_json::json!({ "path": path, "offset": 3, "limit": 3 }),
182                &ctx,
183            )
184            .await
185            .unwrap();
186
187        assert!(!result.is_error);
188        assert!(result.output.contains("3: line 3"));
189        assert!(result.output.contains("4: line 4"));
190        assert!(result.output.contains("5: line 5"));
191        assert!(!result.output.contains("line 1\n") || result.output.starts_with("3:"));
192        assert!(!result.output.contains("6: line 6"));
193    }
194}