Files
owlen/crates/tools/fs/src/lib.rs
vikingowl 6108b9e3d1 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>
2025-11-01 19:19:49 +01:00

130 lines
4.2 KiB
Rust

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();
// Extract the literal prefix to determine the root directory
// Find the position of the first glob metacharacter
let first_glob = pattern
.find(|c| matches!(c, '*' | '?' | '[' | '{'))
.unwrap_or(pattern.len());
// Find the last directory separator before the first glob metacharacter
let root = if first_glob > 0 {
let prefix = &pattern[..first_glob];
prefix.rfind('/').map(|pos| &prefix[..pos]).unwrap_or(".")
} else {
"."
};
let mut out = Vec::new();
for result in WalkBuilder::new(root)
.standard_filters(true)
.git_ignore(true)
.git_global(false)
.git_exclude(false)
.require_git(false)
.build()
{
let entity = result?;
if entity.file_type().map(|filetype| filetype.is_file()).unwrap_or(false) {
if let Some(path) = entity.path().to_str() {
// Match against the glob pattern
if glob.is_match(path) {
out.push(path.to_string());
}
}
}
}
Ok(out)
}
pub fn grep(root: &str, pattern: &str) -> Result<Vec<(String, usize, String)>> {
let matcher = RegexMatcher::new_line_matcher(pattern)?;
let mut searcher = SearcherBuilder::new().line_number(true).build();
let mut results = Vec::new();
for result in WalkBuilder::new(root)
.standard_filters(true)
.git_ignore(true)
.git_global(false)
.git_exclude(false)
.require_git(false)
.build()
{
let entity = result?;
if !entity.file_type().map(|filetype| filetype.is_file()).unwrap_or(false) { continue; }
let path = entity.path().to_path_buf();
let mut line_hits: Vec<(usize, String)> = Vec::new();
let sink = UTF8(|line_number, line| {
line_hits.push((line_number as usize, line.to_string()));
Ok(true)
});
let _ = searcher.search_path(&matcher, &path, sink);
if !line_hits.is_empty() {
let p = path.to_string_lossy().to_string();
for (line_number, text) in line_hits {
results.push((p.clone(), line_number, text));
}
}
}
Ok(results)
}