feat(tools): implement Edit and Write tools with deterministic patches (M3 complete)
This commit implements the complete M3 milestone (Edit & Write tools) including: Write tool: - Creates new files with parent directory creation - Overwrites existing files safely - Simple and straightforward implementation Edit tool: - Exact string replacement with uniqueness enforcement - Detects ambiguous matches (multiple occurrences) and fails safely - Detects no-match scenarios and fails with clear error - Automatic backup before modification - Rollback on write failure (restores from backup) - Supports multiline string replacements CLI integration: - Added `write` subcommand: `owlen write <path> <content>` - Added `edit` subcommand: `owlen edit <path> <old_string> <new_string>` - Permission checks for both Write and Edit tools - Clear error messages for permission denials Permission enforcement: - Plan mode (default): blocks Write and Edit (asks for approval) - AcceptEdits mode: allows Write and Edit - Code mode: allows all operations Testing: - 6 new tests in tools-fs for Write/Edit functionality - 5 new tests in CLI for permission enforcement with Edit/Write - Tests verify plan mode blocks, acceptEdits allows, code mode allows all - All 32 workspace tests passing Dependencies: - Added `similar` crate for future diff/patch enhancements M3 milestone complete! ✅ 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -12,4 +12,7 @@ globset = "0.4"
|
||||
grep-regex = "0.1"
|
||||
grep-searcher = "0.1"
|
||||
color-eyre = "0.6"
|
||||
similar = "2.7"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.23.0"
|
||||
@@ -1,13 +1,62 @@
|
||||
use color_eyre::eyre::Result;
|
||||
use color_eyre::eyre::{Result, eyre};
|
||||
use ignore::WalkBuilder;
|
||||
use grep_regex::RegexMatcher;
|
||||
use grep_searcher::{sinks::UTF8, SearcherBuilder};
|
||||
use globset::Glob;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn read_file(path: &str) -> Result<String> {
|
||||
Ok(std::fs::read_to_string(path)?)
|
||||
}
|
||||
|
||||
pub fn write_file(path: &str, content: &str) -> Result<()> {
|
||||
// Create parent directories if they don't exist
|
||||
if let Some(parent) = Path::new(path).parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn edit_file(path: &str, old_string: &str, new_string: &str) -> Result<()> {
|
||||
// Read the current file content
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
|
||||
// Find all occurrences of old_string
|
||||
let matches: Vec<_> = content.match_indices(old_string).collect();
|
||||
|
||||
match matches.len() {
|
||||
0 => Err(eyre!("String to replace not found in file: '{}'", old_string)),
|
||||
1 => {
|
||||
// Exactly one match - safe to replace
|
||||
let new_content = content.replace(old_string, new_string);
|
||||
|
||||
// Create a backup before modifying
|
||||
let backup_path = format!("{}.backup", path);
|
||||
std::fs::write(&backup_path, &content)?;
|
||||
|
||||
// Write the new content
|
||||
match std::fs::write(path, new_content) {
|
||||
Ok(_) => {
|
||||
// Success - remove backup
|
||||
let _ = std::fs::remove_file(&backup_path);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
// Failed to write - restore from backup
|
||||
let _ = std::fs::rename(&backup_path, path);
|
||||
Err(e.into())
|
||||
}
|
||||
}
|
||||
}
|
||||
n => Err(eyre!(
|
||||
"Ambiguous replacement: found {} occurrences of '{}' in file. Please make the old_string unique.",
|
||||
n,
|
||||
old_string
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn glob_list(pattern: &str) -> Result<Vec<String>> {
|
||||
let glob = Glob::new(pattern)?.compile_matcher();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use tools_fs::{read_file, glob_list, grep};
|
||||
use tools_fs::{read_file, glob_list, grep, write_file, edit_file};
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
@@ -26,4 +26,79 @@ fn grep_finds_lines() {
|
||||
|
||||
let hits = grep(root.to_str().unwrap(), "hello").unwrap();
|
||||
assert!(hits.iter().any(|(_p, _ln, text)| text.contains("hello")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_file_creates_new_file() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("new.txt");
|
||||
|
||||
write_file(file_path.to_str().unwrap(), "new content").unwrap();
|
||||
|
||||
assert_eq!(read_file(file_path.to_str().unwrap()).unwrap(), "new content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_file_overwrites_existing() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("existing.txt");
|
||||
fs::write(&file_path, "old content").unwrap();
|
||||
|
||||
write_file(file_path.to_str().unwrap(), "new content").unwrap();
|
||||
|
||||
assert_eq!(read_file(file_path.to_str().unwrap()).unwrap(), "new content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_file_replaces_exact_match() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("test.txt");
|
||||
let original = "line 1\nline 2\nline 3\n";
|
||||
fs::write(&file_path, original).unwrap();
|
||||
|
||||
edit_file(file_path.to_str().unwrap(), "line 2", "modified line 2").unwrap();
|
||||
|
||||
let result = read_file(file_path.to_str().unwrap()).unwrap();
|
||||
assert_eq!(result, "line 1\nmodified line 2\nline 3\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_file_replaces_multiline() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("test.txt");
|
||||
let original = "line 1\nline 2\nline 3\nline 4\n";
|
||||
fs::write(&file_path, original).unwrap();
|
||||
|
||||
edit_file(file_path.to_str().unwrap(), "line 2\nline 3", "new content").unwrap();
|
||||
|
||||
let result = read_file(file_path.to_str().unwrap()).unwrap();
|
||||
assert_eq!(result, "line 1\nnew content\nline 4\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_file_fails_on_ambiguous_match() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("test.txt");
|
||||
let original = "duplicate\nsome text\nduplicate\n";
|
||||
fs::write(&file_path, original).unwrap();
|
||||
|
||||
let result = edit_file(file_path.to_str().unwrap(), "duplicate", "changed");
|
||||
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(err_msg.contains("Ambiguous") || err_msg.contains("multiple") || err_msg.contains("occurrences"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_file_fails_on_no_match() {
|
||||
let dir = tempdir().unwrap();
|
||||
let file_path = dir.path().join("test.txt");
|
||||
let original = "line 1\nline 2\n";
|
||||
fs::write(&file_path, original).unwrap();
|
||||
|
||||
let result = edit_file(file_path.to_str().unwrap(), "nonexistent", "changed");
|
||||
|
||||
assert!(result.is_err());
|
||||
let err_msg = result.unwrap_err().to_string();
|
||||
assert!(err_msg.contains("not found") || err_msg.contains("String to replace"));
|
||||
}
|
||||
Reference in New Issue
Block a user