Skip to main content

apollo/tools/
file_ops.rs

1//! File operations tools — Read, Write (matching OpenClaw's API).
2
3use async_trait::async_trait;
4use serde::Deserialize;
5use std::path::PathBuf;
6
7use super::sandbox::{resolve_workspace_existing_path, resolve_workspace_write_path};
8use super::traits::*;
9use crate::text::truncate_chars_counted;
10
11// ============================================================
12// Read tool — read file contents with optional offset/limit
13// ============================================================
14
15pub struct FileReadTool {
16    workspace: PathBuf,
17}
18
19impl FileReadTool {
20    pub fn new(workspace: PathBuf) -> Self {
21        Self { workspace }
22    }
23}
24
25#[derive(Deserialize)]
26struct ReadArgs {
27    #[serde(alias = "file_path")]
28    path: String,
29    /// Line number to start reading from (1-indexed)
30    offset: Option<usize>,
31    /// Maximum number of lines to read
32    limit: Option<usize>,
33}
34
35#[async_trait]
36impl Tool for FileReadTool {
37    fn name(&self) -> &str {
38        "Read"
39    }
40
41    fn spec(&self) -> ToolSpec {
42        ToolSpec {
43            name: "Read".to_string(),
44            description: "Read the contents of a file. Use offset/limit for large files."
45                .to_string(),
46            parameters: serde_json::json!({
47                "type": "object",
48                "properties": {
49                    "path": {
50                        "type": "string",
51                        "description": "Path to the file to read (relative or absolute)"
52                    },
53                    "offset": {
54                        "type": "integer",
55                        "description": "Line number to start reading from (1-indexed)"
56                    },
57                    "limit": {
58                        "type": "integer",
59                        "description": "Maximum number of lines to read"
60                    }
61                },
62                "required": ["path"]
63            }),
64        }
65    }
66
67    async fn execute(&self, arguments: &str) -> anyhow::Result<ToolResult> {
68        let args: ReadArgs = serde_json::from_str(arguments)?;
69        let full_path = match resolve_workspace_existing_path(&self.workspace, &args.path) {
70            Ok(p) => p,
71            Err(e) => return Ok(ToolResult::error(e.to_string())),
72        };
73
74        match tokio::fs::read_to_string(&full_path).await {
75            Ok(content) => {
76                let lines: Vec<&str> = content.lines().collect();
77                let total_lines = lines.len();
78
79                let offset = args.offset.unwrap_or(1).max(1) - 1; // Convert 1-indexed to 0-indexed
80                let limit = args.limit.unwrap_or(2000);
81
82                let selected: Vec<&str> = lines.iter().skip(offset).take(limit).copied().collect();
83
84                let result = selected.join("\n");
85
86                // Truncate if too large
87                let truncated = match truncate_chars_counted(&result, 50_000) {
88                    Some((head, dropped)) => {
89                        format!("{}...\n[truncated {} chars]", head, dropped)
90                    }
91                    None => result,
92                };
93
94                let remaining = total_lines.saturating_sub(offset + limit);
95                if remaining > 0 {
96                    Ok(ToolResult::success(format!(
97                        "{}\n\n[{} more lines in file. Use offset={} to continue.]",
98                        truncated,
99                        remaining,
100                        offset + limit + 1
101                    )))
102                } else {
103                    Ok(ToolResult::success(truncated))
104                }
105            }
106            Err(e) => Ok(ToolResult::error(format!(
107                "Cannot read '{}': {}",
108                args.path, e
109            ))),
110        }
111    }
112}
113
114// ============================================================
115// Write tool — create or overwrite files, auto-create parent dirs
116// ============================================================
117
118pub struct FileWriteTool {
119    workspace: PathBuf,
120}
121
122impl FileWriteTool {
123    pub fn new(workspace: PathBuf) -> Self {
124        Self { workspace }
125    }
126}
127
128#[derive(Deserialize)]
129struct WriteArgs {
130    #[serde(alias = "file_path")]
131    path: String,
132    content: String,
133}
134
135#[async_trait]
136impl Tool for FileWriteTool {
137    fn name(&self) -> &str {
138        "Write"
139    }
140
141    fn spec(&self) -> ToolSpec {
142        ToolSpec {
143            name: "Write".to_string(),
144            description: "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Automatically creates parent directories.".to_string(),
145            parameters: serde_json::json!({
146                "type": "object",
147                "properties": {
148                    "path": {
149                        "type": "string",
150                        "description": "Path to the file to write (relative or absolute)"
151                    },
152                    "content": {
153                        "type": "string",
154                        "description": "Content to write to the file"
155                    }
156                },
157                "required": ["path", "content"]
158            }),
159        }
160    }
161
162    async fn execute(&self, arguments: &str) -> anyhow::Result<ToolResult> {
163        let args: WriteArgs = serde_json::from_str(arguments)?;
164        let full_path = match resolve_workspace_write_path(&self.workspace, &args.path) {
165            Ok(p) => p,
166            Err(e) => return Ok(ToolResult::error(e.to_string())),
167        };
168
169        // Create parent directories
170        if let Some(parent) = full_path.parent() {
171            tokio::fs::create_dir_all(parent).await?;
172        }
173
174        match tokio::fs::write(&full_path, &args.content).await {
175            Ok(_) => Ok(ToolResult::success(format!(
176                "Successfully wrote {} bytes to {}",
177                args.content.len(),
178                args.path
179            ))),
180            Err(e) => Ok(ToolResult::error(format!("Failed to write: {}", e))),
181        }
182    }
183}