Skip to main content

sac/tools/
read.rs

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}