use std::path::Path;
use anyhow::Context;
use crate::containment::PathGuard;
use crate::ops::file::{append_content, prepend_content};
use super::{
ApplyMode, ContentEditResult, EditResult, ReplaceOptions, make_diff, replace_in_content,
write_if_apply,
};
use crate::write::WritePolicy;
#[derive(Debug, Clone)]
pub enum ContentEdit {
Replace {
old: String,
new: String,
options: ReplaceOptions,
},
InsertBefore { anchor: String, content: String },
InsertAfter { anchor: String, content: String },
Append { content: String },
Prepend { content: String },
}
#[derive(Debug, Clone)]
pub struct ContentEditsResult {
pub original: String,
pub modified: String,
pub diff: String,
pub changed: bool,
pub ops_applied: usize,
}
pub fn apply_content_edits(
content: &str,
edits: &[ContentEdit],
) -> anyhow::Result<ContentEditsResult> {
let original = content.to_string();
let mut current = content.to_string();
let mut ops_applied = 0usize;
for (i, edit) in edits.iter().enumerate() {
current = apply_one(¤t, edit)
.with_context(|| format!("content edit {} of {} failed", i + 1, edits.len()))?;
ops_applied += 1;
}
let changed = current != original;
let diff = make_diff("<buffer>", &original, ¤t);
Ok(ContentEditsResult {
original,
modified: current,
diff,
changed,
ops_applied,
})
}
#[cfg(any(feature = "cli", feature = "files"))]
pub fn apply_content_edits_to_file(
path: &Path,
edits: &[ContentEdit],
mode: ApplyMode,
guard: Option<&PathGuard>,
) -> anyhow::Result<EditResult> {
if let Some(g) = guard {
g.check_path(&path.to_string_lossy())
.map_err(|e| anyhow::anyhow!("path rejected by workspace guard: {e}"))?;
}
let path_str = path.to_string_lossy().into_owned();
let original =
std::fs::read_to_string(path).with_context(|| format!("failed to read {path_str}"))?;
let batch = apply_content_edits(&original, edits)?;
let policy = WritePolicy::default();
let applied = write_if_apply(path, &batch.modified, mode, &policy, guard)?;
Ok(EditResult {
path: path_str,
original_content: batch.original,
new_content: batch.modified,
diff: batch.diff,
applied,
changed: batch.changed,
action: "content.edits",
dest_path: None,
match_count: 0,
removed: 0,
})
}
fn apply_one(content: &str, edit: &ContentEdit) -> anyhow::Result<String> {
match edit {
ContentEdit::Replace { old, new, options } => {
let result: ContentEditResult = replace_in_content(content, old, new, options)?;
Ok(result.new_content)
}
ContentEdit::InsertBefore {
anchor,
content: insert,
} => {
if anchor.is_empty() {
anyhow::bail!("insert_before anchor must not be empty");
}
match content.find(anchor.as_str()) {
Some(idx) => {
let mut out = String::with_capacity(content.len() + insert.len());
out.push_str(&content[..idx]);
out.push_str(insert);
out.push_str(&content[idx..]);
Ok(out)
}
None => anyhow::bail!("insert_before anchor not found: {anchor:?}"),
}
}
ContentEdit::InsertAfter {
anchor,
content: insert,
} => {
if anchor.is_empty() {
anyhow::bail!("insert_after anchor must not be empty");
}
match content.find(anchor.as_str()) {
Some(idx) => {
let end = idx + anchor.len();
let mut out = String::with_capacity(content.len() + insert.len());
out.push_str(&content[..end]);
out.push_str(insert);
out.push_str(&content[end..]);
Ok(out)
}
None => anyhow::bail!("insert_after anchor not found: {anchor:?}"),
}
}
ContentEdit::Append { content: inject } => Ok(append_content(content, inject)),
ContentEdit::Prepend { content: inject } => Ok(prepend_content(content, inject)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn multi_op_replace_then_append() {
let edits = [
ContentEdit::Replace {
old: "hello".into(),
new: "hi".into(),
options: ReplaceOptions::default(),
},
ContentEdit::Append {
content: "\nworld".into(),
},
];
let r = apply_content_edits("hello\n", &edits).unwrap();
assert!(r.changed);
assert_eq!(r.ops_applied, 2);
assert_eq!(r.modified, "hi\n\nworld");
}
#[test]
fn multi_op_all_or_nothing_on_failure() {
let edits = [
ContentEdit::Replace {
old: "a".into(),
new: "A".into(),
options: ReplaceOptions::default(),
},
ContentEdit::InsertBefore {
anchor: "missing".into(),
content: "x".into(),
},
];
let err = apply_content_edits("a b\n", &edits).unwrap_err();
assert!(
err.to_string().contains("content edit 2"),
"error should identify failing op: {err}"
);
}
#[test]
fn multi_op_unique_failure() {
let edits = [ContentEdit::Replace {
old: "x".into(),
new: "y".into(),
options: ReplaceOptions {
unique: true,
..Default::default()
},
}];
let err = apply_content_edits("x x\n", &edits).unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("ambiguous") || msg.contains("matches"),
"expected unique/ambiguous failure, got: {msg}"
);
}
#[test]
fn multi_op_insert_before_after() {
let edits = [
ContentEdit::InsertBefore {
anchor: "B".into(),
content: "A".into(),
},
ContentEdit::InsertAfter {
anchor: "B".into(),
content: "C".into(),
},
];
let r = apply_content_edits("B", &edits).unwrap();
assert_eq!(r.modified, "ABC");
}
#[test]
fn empty_edits_is_noop() {
let r = apply_content_edits("same\n", &[]).unwrap();
assert!(!r.changed);
assert_eq!(r.ops_applied, 0);
assert_eq!(r.modified, "same\n");
}
#[test]
fn multi_op_prepend() {
let edits = [ContentEdit::Prepend {
content: "pre\n".into(),
}];
let r = apply_content_edits("body\n", &edits).unwrap();
assert_eq!(r.modified, "pre\nbody\n");
assert_eq!(r.ops_applied, 1);
}
#[test]
fn empty_anchor_is_rejected() {
let before = apply_content_edits(
"body\n",
&[ContentEdit::InsertBefore {
anchor: String::new(),
content: "x".into(),
}],
)
.unwrap_err();
let before_msg = format!("{before:#}");
assert!(
before_msg.contains("empty"),
"insert_before empty anchor: {before_msg}"
);
let after = apply_content_edits(
"body\n",
&[ContentEdit::InsertAfter {
anchor: String::new(),
content: "x".into(),
}],
)
.unwrap_err();
let after_msg = format!("{after:#}");
assert!(
after_msg.contains("empty"),
"insert_after empty anchor: {after_msg}"
);
}
}