cersei_tools/tool_primitives/
fs.rs1use super::diff;
6use std::path::Path;
7
8#[derive(Debug, Clone)]
10pub struct FileContent {
11 pub path: String,
12 pub content: String,
13 pub total_lines: usize,
14 pub offset: usize,
15 pub lines_returned: usize,
16}
17
18#[derive(Debug, Clone)]
20pub struct FileMetadata {
21 pub path: String,
22 pub size_bytes: u64,
23 pub is_file: bool,
24 pub is_dir: bool,
25 pub is_symlink: bool,
26 pub modified: Option<u64>,
27 pub readonly: bool,
28}
29
30#[derive(Debug, Clone)]
32pub struct EditResult {
33 pub replacements_made: usize,
34}
35
36#[derive(Debug)]
38pub enum EditError {
39 Io(std::io::Error),
40 NotFound,
42 AmbiguousMatch {
44 count: usize,
45 },
46}
47
48impl std::fmt::Display for EditError {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 match self {
51 Self::Io(e) => write!(f, "I/O error: {e}"),
52 Self::NotFound => write!(f, "old text not found in file"),
53 Self::AmbiguousMatch { count } => {
54 write!(
55 f,
56 "old text found {count} times (use replace_all=true to replace all)"
57 )
58 }
59 }
60 }
61}
62
63impl std::error::Error for EditError {}
64
65impl From<std::io::Error> for EditError {
66 fn from(e: std::io::Error) -> Self {
67 Self::Io(e)
68 }
69}
70
71pub async fn read_file(
76 path: &Path,
77 offset: usize,
78 limit: usize,
79) -> Result<FileContent, std::io::Error> {
80 let raw = tokio::fs::read_to_string(path).await?;
81 let all_lines: Vec<&str> = raw.lines().collect();
82 let total_lines = all_lines.len();
83
84 let end = if limit == 0 {
85 total_lines
86 } else {
87 (offset + limit).min(total_lines)
88 };
89
90 let selected = &all_lines[offset.min(total_lines)..end];
91 let mut content = String::new();
92 for (i, line) in selected.iter().enumerate() {
93 let line_num = offset + i + 1;
94 content.push_str(&format!("{:>6}\t{}\n", line_num, line));
95 }
96
97 Ok(FileContent {
98 path: path.display().to_string(),
99 content,
100 total_lines,
101 offset,
102 lines_returned: selected.len(),
103 })
104}
105
106pub async fn write_file(path: &Path, content: &str) -> Result<(), std::io::Error> {
108 if let Some(parent) = path.parent() {
109 tokio::fs::create_dir_all(parent).await?;
110 }
111 tokio::fs::write(path, content).await
112}
113
114pub async fn edit_file(
120 path: &Path,
121 old_text: &str,
122 new_text: &str,
123 replace_all: bool,
124) -> Result<EditResult, EditError> {
125 let content = tokio::fs::read_to_string(path).await?;
126
127 let count = content.matches(old_text).count();
128 if count == 0 {
129 return Err(EditError::NotFound);
130 }
131 if count > 1 && !replace_all {
132 return Err(EditError::AmbiguousMatch { count });
133 }
134
135 let new_content = if replace_all {
136 content.replace(old_text, new_text)
137 } else {
138 content.replacen(old_text, new_text, 1)
139 };
140
141 tokio::fs::write(path, &new_content).await?;
142
143 Ok(EditResult {
144 replacements_made: if replace_all { count } else { 1 },
145 })
146}
147
148pub async fn diff_file(
150 path: &Path,
151 new_content: &str,
152 context_lines: usize,
153) -> Result<String, std::io::Error> {
154 let old_content = tokio::fs::read_to_string(path).await?;
155 Ok(diff::unified_diff(&old_content, new_content, context_lines))
156}
157
158pub async fn patch_file(path: &Path, patch: &str) -> Result<(), PatchFileError> {
160 let original = tokio::fs::read_to_string(path)
161 .await
162 .map_err(PatchFileError::Io)?;
163 let patched =
164 diff::apply_patch(&original, patch).map_err(|e| PatchFileError::Patch(e.message))?;
165 tokio::fs::write(path, &patched)
166 .await
167 .map_err(PatchFileError::Io)?;
168 Ok(())
169}
170
171#[derive(Debug)]
173pub enum PatchFileError {
174 Io(std::io::Error),
175 Patch(String),
176}
177
178impl std::fmt::Display for PatchFileError {
179 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180 match self {
181 Self::Io(e) => write!(f, "I/O error: {e}"),
182 Self::Patch(msg) => write!(f, "patch failed: {msg}"),
183 }
184 }
185}
186
187impl std::error::Error for PatchFileError {}
188
189pub async fn file_exists(path: &Path) -> bool {
191 tokio::fs::metadata(path).await.is_ok()
192}
193
194pub async fn file_size(path: &Path) -> Result<u64, std::io::Error> {
196 let meta = tokio::fs::metadata(path).await?;
197 Ok(meta.len())
198}
199
200pub async fn file_metadata(path: &Path) -> Result<FileMetadata, std::io::Error> {
202 let meta = tokio::fs::metadata(path).await?;
203 let modified = meta
204 .modified()
205 .ok()
206 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
207 .map(|d| d.as_secs());
208
209 Ok(FileMetadata {
210 path: path.display().to_string(),
211 size_bytes: meta.len(),
212 is_file: meta.is_file(),
213 is_dir: meta.is_dir(),
214 is_symlink: meta.file_type().is_symlink(),
215 modified,
216 readonly: meta.permissions().readonly(),
217 })
218}
219
220#[cfg(test)]
223mod tests {
224 use super::*;
225
226 #[tokio::test]
227 async fn test_read_write() {
228 let tmp = tempfile::tempdir().unwrap();
229 let path = tmp.path().join("test.txt");
230
231 write_file(&path, "line1\nline2\nline3\n").await.unwrap();
232 let fc = read_file(&path, 0, 0).await.unwrap();
233 assert_eq!(fc.total_lines, 3);
234 assert_eq!(fc.lines_returned, 3);
235 assert!(fc.content.contains("line2"));
236 }
237
238 #[tokio::test]
239 async fn test_read_with_offset() {
240 let tmp = tempfile::tempdir().unwrap();
241 let path = tmp.path().join("test.txt");
242 write_file(&path, "a\nb\nc\nd\ne\n").await.unwrap();
243
244 let fc = read_file(&path, 2, 2).await.unwrap();
245 assert_eq!(fc.lines_returned, 2);
246 assert!(fc.content.contains("c"));
247 assert!(fc.content.contains("d"));
248 assert!(!fc.content.contains("a"));
249 }
250
251 #[tokio::test]
252 async fn test_edit_single() {
253 let tmp = tempfile::tempdir().unwrap();
254 let path = tmp.path().join("test.txt");
255 write_file(&path, "hello world").await.unwrap();
256
257 let result = edit_file(&path, "world", "earth", false).await.unwrap();
258 assert_eq!(result.replacements_made, 1);
259
260 let content = tokio::fs::read_to_string(&path).await.unwrap();
261 assert_eq!(content, "hello earth");
262 }
263
264 #[tokio::test]
265 async fn test_edit_not_found() {
266 let tmp = tempfile::tempdir().unwrap();
267 let path = tmp.path().join("test.txt");
268 write_file(&path, "hello").await.unwrap();
269
270 let result = edit_file(&path, "xyz", "abc", false).await;
271 assert!(matches!(result, Err(EditError::NotFound)));
272 }
273
274 #[tokio::test]
275 async fn test_edit_ambiguous() {
276 let tmp = tempfile::tempdir().unwrap();
277 let path = tmp.path().join("test.txt");
278 write_file(&path, "aaa bbb aaa").await.unwrap();
279
280 let result = edit_file(&path, "aaa", "ccc", false).await;
281 assert!(matches!(
282 result,
283 Err(EditError::AmbiguousMatch { count: 2 })
284 ));
285
286 let result = edit_file(&path, "aaa", "ccc", true).await.unwrap();
288 assert_eq!(result.replacements_made, 2);
289 }
290
291 #[tokio::test]
292 async fn test_diff_file() {
293 let tmp = tempfile::tempdir().unwrap();
294 let path = tmp.path().join("test.txt");
295 write_file(&path, "hello\nworld\n").await.unwrap();
296
297 let d = diff_file(&path, "hello\nearth\n", 3).await.unwrap();
298 assert!(d.contains("-world"));
299 assert!(d.contains("+earth"));
300 }
301
302 #[tokio::test]
303 async fn test_file_metadata() {
304 let tmp = tempfile::tempdir().unwrap();
305 let path = tmp.path().join("test.txt");
306 write_file(&path, "content").await.unwrap();
307
308 let meta = file_metadata(&path).await.unwrap();
309 assert!(meta.is_file);
310 assert!(!meta.is_dir);
311 assert_eq!(meta.size_bytes, 7);
312 }
313
314 #[tokio::test]
315 async fn test_file_exists() {
316 let tmp = tempfile::tempdir().unwrap();
317 assert!(!file_exists(&tmp.path().join("nope")).await);
318
319 let path = tmp.path().join("yes.txt");
320 write_file(&path, "").await.unwrap();
321 assert!(file_exists(&path).await);
322 }
323}