use super::diff;
use std::path::Path;
#[derive(Debug, Clone)]
pub struct FileContent {
pub path: String,
pub content: String,
pub total_lines: usize,
pub offset: usize,
pub lines_returned: usize,
}
#[derive(Debug, Clone)]
pub struct FileMetadata {
pub path: String,
pub size_bytes: u64,
pub is_file: bool,
pub is_dir: bool,
pub is_symlink: bool,
pub modified: Option<u64>,
pub readonly: bool,
}
#[derive(Debug, Clone)]
pub struct EditResult {
pub replacements_made: usize,
}
#[derive(Debug)]
pub enum EditError {
Io(std::io::Error),
NotFound,
AmbiguousMatch {
count: usize,
},
}
impl std::fmt::Display for EditError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "I/O error: {e}"),
Self::NotFound => write!(f, "old text not found in file"),
Self::AmbiguousMatch { count } => {
write!(
f,
"old text found {count} times (use replace_all=true to replace all)"
)
}
}
}
}
impl std::error::Error for EditError {}
impl From<std::io::Error> for EditError {
fn from(e: std::io::Error) -> Self {
Self::Io(e)
}
}
pub async fn read_file(
path: &Path,
offset: usize,
limit: usize,
) -> Result<FileContent, std::io::Error> {
let raw = tokio::fs::read_to_string(path).await?;
let all_lines: Vec<&str> = raw.lines().collect();
let total_lines = all_lines.len();
let end = if limit == 0 {
total_lines
} else {
(offset + limit).min(total_lines)
};
let selected = &all_lines[offset.min(total_lines)..end];
let mut content = String::new();
for (i, line) in selected.iter().enumerate() {
let line_num = offset + i + 1;
content.push_str(&format!("{:>6}\t{}\n", line_num, line));
}
Ok(FileContent {
path: path.display().to_string(),
content,
total_lines,
offset,
lines_returned: selected.len(),
})
}
pub async fn write_file(path: &Path, content: &str) -> Result<(), std::io::Error> {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(path, content).await
}
pub async fn edit_file(
path: &Path,
old_text: &str,
new_text: &str,
replace_all: bool,
) -> Result<EditResult, EditError> {
let content = tokio::fs::read_to_string(path).await?;
let count = content.matches(old_text).count();
if count == 0 {
return Err(EditError::NotFound);
}
if count > 1 && !replace_all {
return Err(EditError::AmbiguousMatch { count });
}
let new_content = if replace_all {
content.replace(old_text, new_text)
} else {
content.replacen(old_text, new_text, 1)
};
tokio::fs::write(path, &new_content).await?;
Ok(EditResult {
replacements_made: if replace_all { count } else { 1 },
})
}
pub async fn diff_file(
path: &Path,
new_content: &str,
context_lines: usize,
) -> Result<String, std::io::Error> {
let old_content = tokio::fs::read_to_string(path).await?;
Ok(diff::unified_diff(&old_content, new_content, context_lines))
}
pub async fn patch_file(path: &Path, patch: &str) -> Result<(), PatchFileError> {
let original = tokio::fs::read_to_string(path)
.await
.map_err(PatchFileError::Io)?;
let patched =
diff::apply_patch(&original, patch).map_err(|e| PatchFileError::Patch(e.message))?;
tokio::fs::write(path, &patched)
.await
.map_err(PatchFileError::Io)?;
Ok(())
}
#[derive(Debug)]
pub enum PatchFileError {
Io(std::io::Error),
Patch(String),
}
impl std::fmt::Display for PatchFileError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "I/O error: {e}"),
Self::Patch(msg) => write!(f, "patch failed: {msg}"),
}
}
}
impl std::error::Error for PatchFileError {}
pub async fn file_exists(path: &Path) -> bool {
tokio::fs::metadata(path).await.is_ok()
}
pub async fn file_size(path: &Path) -> Result<u64, std::io::Error> {
let meta = tokio::fs::metadata(path).await?;
Ok(meta.len())
}
pub async fn file_metadata(path: &Path) -> Result<FileMetadata, std::io::Error> {
let meta = tokio::fs::metadata(path).await?;
let modified = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs());
Ok(FileMetadata {
path: path.display().to_string(),
size_bytes: meta.len(),
is_file: meta.is_file(),
is_dir: meta.is_dir(),
is_symlink: meta.file_type().is_symlink(),
modified,
readonly: meta.permissions().readonly(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_read_write() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("test.txt");
write_file(&path, "line1\nline2\nline3\n").await.unwrap();
let fc = read_file(&path, 0, 0).await.unwrap();
assert_eq!(fc.total_lines, 3);
assert_eq!(fc.lines_returned, 3);
assert!(fc.content.contains("line2"));
}
#[tokio::test]
async fn test_read_with_offset() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("test.txt");
write_file(&path, "a\nb\nc\nd\ne\n").await.unwrap();
let fc = read_file(&path, 2, 2).await.unwrap();
assert_eq!(fc.lines_returned, 2);
assert!(fc.content.contains("c"));
assert!(fc.content.contains("d"));
assert!(!fc.content.contains("a"));
}
#[tokio::test]
async fn test_edit_single() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("test.txt");
write_file(&path, "hello world").await.unwrap();
let result = edit_file(&path, "world", "earth", false).await.unwrap();
assert_eq!(result.replacements_made, 1);
let content = tokio::fs::read_to_string(&path).await.unwrap();
assert_eq!(content, "hello earth");
}
#[tokio::test]
async fn test_edit_not_found() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("test.txt");
write_file(&path, "hello").await.unwrap();
let result = edit_file(&path, "xyz", "abc", false).await;
assert!(matches!(result, Err(EditError::NotFound)));
}
#[tokio::test]
async fn test_edit_ambiguous() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("test.txt");
write_file(&path, "aaa bbb aaa").await.unwrap();
let result = edit_file(&path, "aaa", "ccc", false).await;
assert!(matches!(
result,
Err(EditError::AmbiguousMatch { count: 2 })
));
let result = edit_file(&path, "aaa", "ccc", true).await.unwrap();
assert_eq!(result.replacements_made, 2);
}
#[tokio::test]
async fn test_diff_file() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("test.txt");
write_file(&path, "hello\nworld\n").await.unwrap();
let d = diff_file(&path, "hello\nearth\n", 3).await.unwrap();
assert!(d.contains("-world"));
assert!(d.contains("+earth"));
}
#[tokio::test]
async fn test_file_metadata() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("test.txt");
write_file(&path, "content").await.unwrap();
let meta = file_metadata(&path).await.unwrap();
assert!(meta.is_file);
assert!(!meta.is_dir);
assert_eq!(meta.size_bytes, 7);
}
#[tokio::test]
async fn test_file_exists() {
let tmp = tempfile::tempdir().unwrap();
assert!(!file_exists(&tmp.path().join("nope")).await);
let path = tmp.path().join("yes.txt");
write_file(&path, "").await.unwrap();
assert!(file_exists(&path).await);
}
}