Skip to main content

cersei_tools/tool_primitives/
fs.rs

1//! Async file operation primitives.
2//!
3//! Read, write, edit, diff, and patch files. All async via tokio::fs.
4
5use super::diff;
6use std::path::Path;
7
8/// File content with metadata.
9#[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/// File metadata.
19#[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/// Result of an edit operation.
31#[derive(Debug, Clone)]
32pub struct EditResult {
33    pub replacements_made: usize,
34}
35
36/// Edit errors.
37#[derive(Debug)]
38pub enum EditError {
39    Io(std::io::Error),
40    /// The old text was not found in the file.
41    NotFound,
42    /// The old text appears multiple times and replace_all is false.
43    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
71/// Read a file with optional line offset and limit.
72///
73/// Returns content formatted with 1-based line numbers (`cat -n` style).
74/// `offset` is 0-based. `limit` of 0 means read all lines.
75pub 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
106/// Write content to a file, creating parent directories automatically.
107pub 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
114/// String replacement in a file.
115///
116/// If `replace_all` is false and the old text appears more than once,
117/// returns `EditError::AmbiguousMatch`. If old text is not found,
118/// returns `EditError::NotFound`.
119pub 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
148/// Produce a unified diff between the file's current content and proposed new content.
149pub 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
158/// Apply a unified diff patch to a file.
159pub 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/// Errors from patch_file.
172#[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
189/// Check if a file exists.
190pub async fn file_exists(path: &Path) -> bool {
191    tokio::fs::metadata(path).await.is_ok()
192}
193
194/// Get file size in bytes.
195pub 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
200/// Get detailed file metadata.
201pub 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// ─── Tests ─────────────────────────────────────────────────────────────────
221
222#[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        // replace_all works
287        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}