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:
2025-11-01 19:19:49 +01:00
parent a6cf8585ef
commit 6108b9e3d1
5 changed files with 264 additions and 5 deletions

View File

@@ -8,9 +8,11 @@ use std::io::{self, Write};
#[derive(clap::Subcommand, Debug)]
enum Cmd {
Read {path: String},
Glob {pattern: String},
Grep {root: String, pattern: String},
Read { path: String },
Glob { pattern: String },
Grep { root: String, pattern: String },
Write { path: String, content: String },
Edit { path: String, old_string: String, new_string: String },
}
#[derive(Parser, Debug)]
@@ -105,6 +107,42 @@ async fn main() -> Result<()> {
}
}
}
Cmd::Write { path, content } => {
// Check permission
match perms.check(Tool::Write, None) {
PermissionDecision::Allow => {
tools_fs::write_file(&path, &content)?;
println!("File written: {}", path);
return Ok(());
}
PermissionDecision::Ask => {
return Err(eyre!(
"Permission denied: Write operation requires approval. Use --mode acceptEdits or --mode code to allow."
));
}
PermissionDecision::Deny => {
return Err(eyre!("Permission denied: Write operation is blocked."));
}
}
}
Cmd::Edit { path, old_string, new_string } => {
// Check permission
match perms.check(Tool::Edit, None) {
PermissionDecision::Allow => {
tools_fs::edit_file(&path, &old_string, &new_string)?;
println!("File edited: {}", path);
return Ok(());
}
PermissionDecision::Ask => {
return Err(eyre!(
"Permission denied: Edit operation requires approval. Use --mode acceptEdits or --mode code to allow."
));
}
PermissionDecision::Deny => {
return Err(eyre!("Permission denied: Edit operation is blocked."));
}
}
}
}
}

View File

@@ -54,3 +54,97 @@ fn mode_override_via_cli_flag() {
.arg(file.to_str().unwrap());
cmd.assert().success().stdout("content\n");
}
#[test]
fn plan_mode_blocks_write_operations() {
let dir = tempdir().unwrap();
let file = dir.path().join("new.txt");
// Write operation should be blocked in plan mode (default)
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
cmd.arg("write").arg(file.to_str().unwrap()).arg("content");
cmd.assert().failure();
}
#[test]
fn plan_mode_blocks_edit_operations() {
let dir = tempdir().unwrap();
let file = dir.path().join("test.txt");
fs::write(&file, "old content").unwrap();
// Edit operation should be blocked in plan mode (default)
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
cmd.arg("edit")
.arg(file.to_str().unwrap())
.arg("old")
.arg("new");
cmd.assert().failure();
}
#[test]
fn accept_edits_mode_allows_write() {
let dir = tempdir().unwrap();
let file = dir.path().join("new.txt");
// Write operation should work in acceptEdits mode
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
cmd.arg("--mode")
.arg("acceptEdits")
.arg("write")
.arg(file.to_str().unwrap())
.arg("new content");
cmd.assert().success();
// Verify file was written
assert_eq!(fs::read_to_string(&file).unwrap(), "new content");
}
#[test]
fn accept_edits_mode_allows_edit() {
let dir = tempdir().unwrap();
let file = dir.path().join("test.txt");
fs::write(&file, "line 1\nline 2\nline 3").unwrap();
// Edit operation should work in acceptEdits mode
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
cmd.arg("--mode")
.arg("acceptEdits")
.arg("edit")
.arg(file.to_str().unwrap())
.arg("line 2")
.arg("modified line");
cmd.assert().success();
// Verify file was edited
assert_eq!(
fs::read_to_string(&file).unwrap(),
"line 1\nmodified line\nline 3"
);
}
#[test]
fn code_mode_allows_all_operations() {
let dir = tempdir().unwrap();
let file = dir.path().join("test.txt");
// Write in code mode
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
cmd.arg("--mode")
.arg("code")
.arg("write")
.arg(file.to_str().unwrap())
.arg("initial content");
cmd.assert().success();
// Edit in code mode
let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("owlen"));
cmd.arg("--mode")
.arg("code")
.arg("edit")
.arg(file.to_str().unwrap())
.arg("initial")
.arg("modified");
cmd.assert().success();
assert_eq!(fs::read_to_string(&file).unwrap(), "modified content");
}

View File

@@ -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"

View File

@@ -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();

View File

@@ -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"));
}