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>
130 lines
4.2 KiB
Rust
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)
|
|
} |