use crate::Sandbox;
use anyhow::{Context, Result};
use rmcp::schemars;
use serde::{Deserialize, Serialize};
use similar::{ChangeTag, TextDiff};
use std::io::Write;
use tempfile::NamedTempFile;
use tokio::fs;
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct Edits {
pub diff: String,
pub applied: bool,
}
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct EditResult {
pub edits: Option<Edits>,
pub success: bool,
pub message: String,
}
#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
pub struct Edit {
pub old: String,
pub new: String,
}
fn apply_edits(mut content: String, edits: Vec<Edit>) -> String {
for edit in edits {
if !content.contains(&edit.old) {
tracing::error!("Content not found in file {}", &edit.old);
} else {
content = content.replace(&edit.old, &edit.new);
}
}
content
}
fn get_diffs(old_content: &str, new_content: &str) -> Vec<String> {
let mut result: Vec<String> = vec![];
let diff = TextDiff::from_lines(old_content, new_content);
for change in diff.iter_all_changes() {
let sign = match change.tag() {
ChangeTag::Delete => "-",
ChangeTag::Insert => "+",
ChangeTag::Equal => " ",
};
result.push(format!("{}{}", sign, change));
}
result
}
pub async fn edit_file(
sandbox: &Sandbox,
path: &str,
edits: Vec<Edit>,
dry_run: bool,
) -> Result<Edits> {
tracing::info!("edit file, dry run: {} path {}", dry_run, path);
let canon_path = sandbox.resolve_path(path).await?;
let original: String = fs::read_to_string(&canon_path)
.await
.with_context(|| format!("Could not read file '{}'", canon_path.display()))?;
let new_content = apply_edits(original.clone(), edits);
let diff = get_diffs(&original, &new_content).join("");
if !dry_run {
let dir = canon_path
.parent()
.ok_or_else(|| anyhow::anyhow!("Invalid path"))?;
let mut tmp = NamedTempFile::new_in(dir)
.with_context(|| format!("Could not create temp file in '{}'", dir.display()))?;
std::io::Write::write_all(&mut tmp, new_content.as_bytes())
.with_context(|| format!("Could not write to temp file in '{}'", dir.display()))?;
tmp.flush()?;
tmp.persist(&canon_path)
.with_context(|| format!("Could not persist edits to '{}'", canon_path.display()))?;
tracing::info!("persited file {}", canon_path.display());
}
Ok(Edits {
diff,
applied: !dry_run,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Sandbox;
use std::sync::Arc;
use tempfile::TempDir;
use tokio::fs;
async fn setup_sandbox(temp_dir: &TempDir) -> Arc<Sandbox> {
let root = temp_dir.path().to_string_lossy().to_string();
Arc::new(Sandbox::new(vec![root]).await.expect("sandbox init failed"))
}
#[tokio::test]
async fn test_edit_file_no_edits_dry_run() {
let temp_dir = TempDir::new().unwrap();
let sandbox = setup_sandbox(&temp_dir).await;
let path = temp_dir.path().join("file.txt");
fs::write(&path, "content").await.unwrap();
let result = edit_file(&sandbox, path.to_str().unwrap(), vec![], true)
.await
.unwrap();
assert_eq!(result.diff, " content\n"); // diff returns original
assert!(!result.applied); // dry run
assert_eq!(fs::read_to_string(&path).await.unwrap(), "content");
}
#[tokio::test]
async fn test_edit_file_apply_edits() {
let temp_dir = TempDir::new().unwrap();
let sandbox = setup_sandbox(&temp_dir).await;
let path = temp_dir.path().join("file.txt");
fs::write(&path, "hello world").await.unwrap();
let edits = vec![Edit {
old: "world".to_string(),
new: "Rust".to_string(),
}];
let result = edit_file(&sandbox, path.to_str().unwrap(), edits, false)
.await
.unwrap();
assert!(result.applied);
let content = fs::read_to_string(&path).await.unwrap();
assert_eq!(content, "hello Rust");
assert!(result.diff.contains("+hello Rust") || result.diff.contains("-hello world"));
}
#[tokio::test]
async fn test_edit_file_partial_match() {
let temp_dir = TempDir::new().unwrap();
let sandbox = setup_sandbox(&temp_dir).await;
let path = temp_dir.path().join("file.txt");
fs::write(&path, "abc123").await.unwrap();
let edits = vec![Edit {
old: "xyz".to_string(), // does not exist in content
new: "123".to_string(),
}];
let result = edit_file(&sandbox, path.to_str().unwrap(), edits, false)
.await
.unwrap();
// File content should remain unchanged
let content = fs::read_to_string(&path).await.unwrap();
assert_eq!(content, "abc123");
// applied is still true because dry_run = false, even though no change occurred
assert!(result.applied);
// Diff should indicate no actual changes
assert!(result.diff.contains(" abc123"));
}
#[tokio::test]
async fn test_edit_file_multiple_edits() {
let temp_dir = TempDir::new().unwrap();
let sandbox = setup_sandbox(&temp_dir).await;
let path = temp_dir.path().join("file.txt");
fs::write(&path, "line1\nline2\nline3").await.unwrap();
let edits = vec![
Edit {
old: "line1".to_string(),
new: "L1".to_string(),
},
Edit {
old: "line3".to_string(),
new: "L3".to_string(),
},
];
let result = edit_file(&sandbox, path.to_str().unwrap(), edits, false)
.await
.unwrap();
let content = fs::read_to_string(&path).await.unwrap();
assert_eq!(content, "L1\nline2\nL3");
assert!(result.diff.contains("-line1") && result.diff.contains("+L1"));
assert!(result.diff.contains("-line3") && result.diff.contains("+L3"));
}
#[tokio::test]
async fn test_edit_file_unicode_content() {
let temp_dir = TempDir::new().unwrap();
let sandbox = setup_sandbox(&temp_dir).await;
let path = temp_dir.path().join("file.txt");
fs::write(&path, "你好世界 🦀").await.unwrap();
let edits = vec![Edit {
old: "世界".to_string(),
new: "Rust".to_string(),
}];
let result = edit_file(&sandbox, path.to_str().unwrap(), edits, false)
.await
.unwrap();
let content = fs::read_to_string(&path).await.unwrap();
assert_eq!(content, "你好Rust 🦀");
assert!(result.diff.contains("-你好世界 🦀") || result.diff.contains("+你好Rust 🦀"));
}
#[tokio::test]
async fn test_edit_file_dry_run_applied_false() {
let temp_dir = TempDir::new().unwrap();
let sandbox = setup_sandbox(&temp_dir).await;
let path = temp_dir.path().join("file.txt");
fs::write(&path, "original").await.unwrap();
let edits = vec![Edit {
old: "original".to_string(),
new: "changed".to_string(),
}];
let result = edit_file(&sandbox, path.to_str().unwrap(), edits, true)
.await
.unwrap();
assert!(!result.applied);
assert_eq!(fs::read_to_string(&path).await.unwrap(), "original");
}
#[tokio::test]
async fn test_apply_edits_some_missing() {
let temp_dir = TempDir::new().unwrap();
let sandbox = setup_sandbox(&temp_dir).await;
let path = temp_dir.path().join("file.txt");
fs::write(&path, "abc def").await.unwrap();
let edits = vec![
Edit {
old: "abc".into(),
new: "ABC".into(),
},
Edit {
old: "xyz".into(),
new: "XYZ".into(),
}, // does not exist
];
let result = edit_file(&sandbox, path.to_str().unwrap(), edits, false)
.await
.unwrap();
let content = fs::read_to_string(&path).await.unwrap();
assert_eq!(content, "ABC def"); // only "abc" replaced
assert!(result.diff.contains("-abc def") || result.diff.contains("+ABC def"));
}
#[cfg(unix)]
#[tokio::test]
async fn test_edit_file_temp_write_error() {
use std::os::unix::fs::PermissionsExt;
let temp_dir = TempDir::new().unwrap();
let sandbox = setup_sandbox(&temp_dir).await;
let path = temp_dir.path().join("file.txt");
fs::write(&path, "content").await.unwrap();
// Make parent dir read-only
let mut perms = fs::metadata(temp_dir.path()).await.unwrap().permissions();
perms.set_mode(0o555);
fs::set_permissions(temp_dir.path(), perms).await.unwrap();
let edits = vec![Edit {
old: "content".into(),
new: "new".into(),
}];
let result = edit_file(&sandbox, path.to_str().unwrap(), edits, false).await;
assert!(result.is_err());
// Restore permissions for cleanup
let mut perms = fs::metadata(temp_dir.path()).await.unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(temp_dir.path(), perms).await.unwrap();
}
#[tokio::test]
async fn test_edit_file_empty_file() {
let temp_dir = TempDir::new().unwrap();
let sandbox = setup_sandbox(&temp_dir).await;
let path = temp_dir.path().join("empty.txt");
fs::write(&path, "").await.unwrap();
let edits = vec![Edit {
old: "foo".into(),
new: "bar".into(),
}];
let result = edit_file(&sandbox, path.to_str().unwrap(), edits, false)
.await
.unwrap();
assert_eq!(fs::read_to_string(&path).await.unwrap(), "");
assert!(result.applied);
}
#[tokio::test]
async fn test_edit_file_invalid_path() {
let temp_dir = TempDir::new().unwrap();
let sandbox = setup_sandbox(&temp_dir).await;
let result = edit_file(&sandbox, "/invalid/path.txt", vec![], true).await;
assert!(result.is_err());
}
}