xcodeai/tools/
file_read.rs1use 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 let output = if ctx.compact_mode && selected.len() > 50 {
81 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}