diff --git a/crates/app/cli/src/main.rs b/crates/app/cli/src/main.rs index 8989ed8..50a2241 100644 --- a/crates/app/cli/src/main.rs +++ b/crates/app/cli/src/main.rs @@ -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.")); + } + } + } } } diff --git a/crates/app/cli/tests/permissions.rs b/crates/app/cli/tests/permissions.rs index 01a8f1e..2c33bcb 100644 --- a/crates/app/cli/tests/permissions.rs +++ b/crates/app/cli/tests/permissions.rs @@ -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"); +} diff --git a/crates/tools/fs/Cargo.toml b/crates/tools/fs/Cargo.toml index 69d5b2e..ad37b97 100644 --- a/crates/tools/fs/Cargo.toml +++ b/crates/tools/fs/Cargo.toml @@ -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" \ No newline at end of file diff --git a/crates/tools/fs/src/lib.rs b/crates/tools/fs/src/lib.rs index 768f7ce..63ee410 100644 --- a/crates/tools/fs/src/lib.rs +++ b/crates/tools/fs/src/lib.rs @@ -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 { 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> { let glob = Glob::new(pattern)?.compile_matcher(); diff --git a/crates/tools/fs/tests/fs_tools.rs b/crates/tools/fs/tests/fs_tools.rs index e4a37ed..e42862a 100644 --- a/crates/tools/fs/tests/fs_tools.rs +++ b/crates/tools/fs/tests/fs_tools.rs @@ -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")); } \ No newline at end of file