1use std::path::PathBuf;
2
3use serde_json::Value;
4
5use crate::tools::{require_str, ToolResult, ToolRuntime};
6
7const SANDBOX_READ_SCRIPT: &str = r#"
8from pathlib import Path
9import sys
10
11orig = sys.argv[1]
12path = Path(sys.argv[2])
13offset = int(sys.argv[3])
14limit = int(sys.argv[4])
15
16if not path.exists():
17 print(f"File not found: {orig}")
18 sys.exit(2)
19
20raw = path.read_bytes()
21check_len = min(len(raw), 8192)
22if b'\0' in raw[:check_len]:
23 print(f"Binary file, cannot read as text: {orig}")
24 sys.exit(2)
25
26text = raw.decode('utf-8', errors='replace')
27lines = text.splitlines()
28total_lines = len(lines)
29selected = lines[offset:offset + limit]
30
31output = ''.join(f"{offset + idx + 1:4}| {line}\n" for idx, line in enumerate(selected))
32if len(output) > 30000:
33 output = output[:30000] + f"\n... (truncated, {total_lines} total lines)"
34elif offset + len(selected) < total_lines:
35 output += f"\n... (showing lines {offset + 1}-{offset + len(selected)} of {total_lines})"
36
37sys.stdout.write(output)
38"#;
39
40pub async fn execute(args: Value, runtime: &ToolRuntime) -> ToolResult {
41 let path = match require_str(&args, "path") {
42 Ok(value) => value,
43 Err(error) => return error,
44 };
45 let offset = args.get("offset").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
46 let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(2000) as usize;
47
48 if let Some(sandbox) = &runtime.sandbox {
49 let guest_path = match sandbox.resolve_path(&path) {
50 Ok(path) => path,
51 Err(error) => {
52 return ToolResult {
53 content: error.to_string(),
54 is_error: true,
55 }
56 }
57 };
58
59 let args = vec![
60 "-c".to_string(),
61 SANDBOX_READ_SCRIPT.to_string(),
62 path.clone(),
63 guest_path.display().to_string(),
64 offset.to_string(),
65 limit.to_string(),
66 ];
67
68 return match sandbox.exec("python3", &args, None).await {
69 Ok(output) => sandbox_output(output),
70 Err(error) => ToolResult {
71 content: format!("Error reading {} in sandbox: {}", path, error),
72 is_error: true,
73 },
74 };
75 }
76
77 let path = PathBuf::from(path);
78 if !path.exists() {
79 return ToolResult {
80 content: format!("File not found: {}", path.display()),
81 is_error: true,
82 };
83 }
84
85 let raw = match tokio::fs::read(&path).await {
86 Ok(b) => b,
87 Err(e) => {
88 return ToolResult {
89 content: format!("Error reading {}: {}", path.display(), e),
90 is_error: true,
91 };
92 }
93 };
94
95 let check_len = raw.len().min(8192);
96 if raw[..check_len].contains(&0u8) {
97 return ToolResult {
98 content: format!("Binary file, cannot read as text: {}", path.display()),
99 is_error: true,
100 };
101 }
102
103 let text = String::from_utf8_lossy(&raw).into_owned();
104 let lines: Vec<&str> = text.lines().collect();
105 let total_lines = lines.len();
106 let selected: Vec<&str> = lines.iter().skip(offset).take(limit).copied().collect();
107
108 let mut output = String::new();
109 for (idx, line) in selected.iter().enumerate() {
110 output.push_str(&format!("{:4}| {}\n", offset + idx + 1, line));
111 }
112
113 if output.len() > 30_000 {
114 output.truncate(30_000);
115 output.push_str(&format!("\n... (truncated, {} total lines)", total_lines));
116 } else if offset + selected.len() < total_lines {
117 output.push_str(&format!(
118 "\n... (showing lines {}-{} of {})",
119 offset + 1,
120 offset + selected.len(),
121 total_lines
122 ));
123 }
124
125 ToolResult {
126 content: output,
127 is_error: false,
128 }
129}
130
131fn sandbox_output(output: std::process::Output) -> ToolResult {
132 let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
133 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
134 if output.status.success() {
135 ToolResult {
136 content: stdout,
137 is_error: false,
138 }
139 } else {
140 let content = if !stdout.trim().is_empty() {
141 stdout
142 } else {
143 stderr
144 };
145 ToolResult {
146 content: content.trim().to_string(),
147 is_error: true,
148 }
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use serde_json::json;
155 use std::collections::HashSet;
156 use std::sync::Arc;
157
158 use tokio::sync::Mutex;
159
160 use super::*;
161 use crate::events::EventSink;
162
163 fn local_runtime() -> ToolRuntime {
164 ToolRuntime {
165 store_path: PathBuf::new(),
166 session_id: None,
167 worker_executable: None,
168 active_threads: Arc::new(Mutex::new(HashSet::new())),
169 event_sink: EventSink::none(),
170 sandbox: None,
171 mcp: None,
172 skills: None,
173 activated_skills: Arc::new(Mutex::new(HashSet::new())),
174 terminal_manager: crate::terminal::TerminalManager::new(),
175 thread_timeout_secs: crate::tools::thread::DEFAULT_THREAD_TIMEOUT_SECS,
176 }
177 }
178
179 #[tokio::test]
180 async fn test_read_missing_file() {
181 let result = execute(
182 json!({ "path": "/nonexistent/file_xyz_12345.txt" }),
183 &local_runtime(),
184 )
185 .await;
186 assert!(result.is_error);
187 assert!(
188 result.content.contains("not found") || result.content.contains("not exist"),
189 "Got: {}",
190 result.content
191 );
192 }
193
194 #[tokio::test]
195 async fn test_read_existing_file() {
196 let result = execute(json!({ "path": "Cargo.toml" }), &local_runtime()).await;
197 assert!(!result.is_error, "Got error: {}", result.content);
198 assert!(
199 result.content.contains("[workspace]") || result.content.contains("[package]"),
200 "Got: {}",
201 result.content
202 );
203 }
204}