use std::collections::HashMap;
use std::path::Path;
use std::sync::PoisonError;
use cosh_sdk::hashline::normalize::{normalize_to_lf, strip_bom};
use cosh_sdk::hashline::snapshots::{Snapshot, SnapshotStore};
use cosh_sdk::rollback;
use super::edit::{EditBatchError, EditResult, edit_target};
use super::fuzzy;
use super::types::{FsMetadata, ReplaceEdit};
use crate::util::path_guard::assert_editable_file;
pub(crate) async fn content_edit(
metadata: &FsMetadata,
edits: &[ReplaceEdit],
dry_run: bool,
) -> Result<Vec<EditResult>, EditBatchError> {
let mut results = Vec::new();
let mut chained: HashMap<String, String> = HashMap::new();
for (i, entry) in edits.iter().enumerate() {
match replace_one(entry, metadata, &mut chained, dry_run).await {
Ok(result) => results.push(result),
Err(cause) => {
return Err(EditBatchError {
failed_path: entry.path.clone(),
cause,
applied: results,
skipped: edits[i + 1..].iter().map(|e| e.path.clone()).collect(),
});
}
}
}
Ok(results)
}
async fn replace_one(
entry: &ReplaceEdit,
metadata: &FsMetadata,
chained: &mut HashMap<String, String>,
dry_run: bool,
) -> Result<EditResult, String> {
if entry.old_string.is_empty() {
return Err(
"old_string must not be empty; to create or overwrite whole files use the write tool."
.to_string(),
);
}
let validated_path = metadata.fs_guard(&entry.path)?;
let canonical = validated_path.to_string_lossy().to_string();
assert_editable_file(Path::new(&canonical))?;
let hash = entry
.file_hash
.clone()
.or_else(|| chained.get(&canonical).cloned())
.ok_or_else(|| missing_tag_message(&entry.path))?;
let snapshot = fetch_snapshot(&entry.path, &canonical, &hash).ok_or_else(|| {
match std::fs::read(&canonical) {
Ok(bytes) if bytes.contains(&0) => binary_message(&entry.path),
Ok(bytes) => {
let text = normalize_to_lf(&strip_bom(&String::from_utf8_lossy(&bytes)).text);
if text.len() > rollback::MAX_SNAPSHOT_BYTES {
oversize_message(&entry.path, text.len())
} else {
unknown_tag_message(&entry.path, &hash)
}
}
Err(_) => unknown_tag_message(&entry.path, &hash),
}
})?;
let old = normalize_to_lf(&entry.old_string);
let new = normalize_to_lf(&entry.new_string);
let occurrences = find_occurrences(&snapshot.text, &old);
if occurrences.is_empty() {
return Err(no_match_message(&entry.path, &snapshot.text, &old));
}
if occurrences.len() > 1 && !entry.replace_all {
return Err(ambiguous_message(&entry.path, &snapshot.text, &old));
}
let ops = build_ops(&snapshot.text, &new, &occurrences)?;
let expected = if entry.replace_all {
snapshot.text.replace(&old, &new)
} else {
snapshot.text.replacen(&old, &new, 1)
};
let target = super::types::EditTarget {
path: entry.path.clone(),
file_hash: hash,
ops,
};
let result = edit_target(target, metadata, Some(&expected), dry_run).await?;
if result.dry_run.is_none() {
chained.insert(canonical, result.file_hash.clone());
}
Ok(result)
}
fn fetch_snapshot(path: &str, canonical: &str, hash: &str) -> Option<Snapshot> {
let store = rollback::session_store();
let mut guard = store.lock().unwrap_or_else(PoisonError::into_inner);
guard
.by_hash(path, hash)
.or_else(|| guard.by_hash(canonical, hash))
}
fn find_occurrences(text: &str, needle: &str) -> Vec<(usize, usize)> {
let mut out = Vec::new();
let mut start = 0;
while let Some(relative) = text[start..].find(needle) {
let begin = start + relative;
let end = begin + needle.len();
out.push((begin, end));
start = end;
}
out
}
fn line_col_at(text: &str, offset: usize) -> (u32, usize) {
let before = &text[..offset];
let line = u32::try_from(before.matches('\n').count() + 1).unwrap_or(u32::MAX);
let column = offset - before.rfind('\n').map_or(0, |i| i + 1);
(line, column)
}
fn build_ops(text: &str, new: &str, occurrences: &[(usize, usize)]) -> Result<String, String> {
let lines: Vec<&str> = text.split('\n').collect();
let mut hunks: Vec<String> = Vec::new();
let mut group_start = 0;
while group_start < occurrences.len() {
let (span_begin, mut span_end) = occurrences[group_start];
let mut span_last_line = line_col_at(text, span_end).0;
let mut group_end = group_start + 1;
while group_end < occurrences.len() {
let (begin, end) = occurrences[group_end];
if line_col_at(text, begin).0 > span_last_line {
break;
}
span_end = end;
span_last_line = line_col_at(text, end).0;
group_end += 1;
}
let (span_first_line, first_col) = line_col_at(text, span_begin);
let (end_line, end_col) = line_col_at(text, span_end);
let consumes_terminator = end_col == 0;
let prefix = &lines[span_first_line as usize - 1][..first_col];
let suffix = if consumes_terminator {
""
} else {
&lines[end_line as usize - 1][end_col..]
};
let mut payload_core = String::with_capacity(span_end - span_begin);
let mut cursor = span_begin;
for &(begin, end) in &occurrences[group_start..group_end] {
payload_core.push_str(&text[cursor..begin]);
payload_core.push_str(new);
cursor = end;
}
payload_core.push_str(&text[cursor..span_end]);
let mut payload_text;
let last_line;
if !consumes_terminator {
last_line = end_line;
payload_text = payload_core;
} else if new.ends_with('\n') {
payload_core.pop();
last_line = end_line - 1;
payload_text = payload_core;
} else if span_end == text.len() && text.ends_with('\n') {
if payload_core.is_empty() && prefix.is_empty() {
hunks.push(delete_hunk(span_first_line, end_line - 1));
group_start = group_end;
continue;
}
return Err(joins_past_eof_message(span_first_line, end_line - 1));
} else {
let next_line = lines[end_line as usize - 1];
last_line = end_line;
payload_text = payload_core;
payload_text.push_str(next_line);
}
let payload_text = format!("{prefix}{payload_text}{suffix}");
hunks.push(replace_hunk(span_first_line, last_line, &payload_text));
group_start = group_end;
}
Ok(hunks.join("\n"))
}
fn replace_hunk(start: u32, end: u32, payload_text: &str) -> String {
let mut hunk = if start == end {
format!("replace {start}:")
} else {
format!("replace {start}..{end}:")
};
for line in payload_text.split('\n') {
hunk.push_str("\n+");
hunk.push_str(line);
}
hunk
}
fn delete_hunk(start: u32, end: u32) -> String {
if start == end {
format!("delete {start}")
} else {
format!("delete {start}..{end}")
}
}
fn missing_tag_message(path: &str) -> String {
format!(
"Missing hashline snapshot tag for content edit to {path}; first read the file \
(the read result carries a ¶path#TAG header), then pass that tag as `file_hash`."
)
}
fn unknown_tag_message(path: &str, hash: &str) -> String {
format!(
"No snapshot recorded for tag #{hash} of {path} in this session. Re-read the \
file with `read` to mint a current ¶path#TAG header — never invent the tag."
)
}
fn oversize_message(path: &str, size: usize) -> String {
format!(
"Cannot content-replace {path}: it is {size} bytes, over the {}-byte session \
snapshot budget, so no tag can anchor it. Use the `targets` engine \
(hashline line/block ops) for this file.",
rollback::MAX_SNAPSHOT_BYTES
)
}
fn binary_message(path: &str) -> String {
format!(
"Cannot content-replace {path}: its content is binary, and binary files \
are never anchored by a session snapshot tag."
)
}
fn joins_past_eof_message(start_line: u32, end_line: u32) -> String {
format!(
"old_string spans lines {start_line}..{end_line} up to the file's final newline \
and new_string would join the replacement with a following line that does \
not exist (the result would lose the trailing newline). End new_string with \
a newline, or include the trailing newline situation in the edit, e.g. via \
the `targets` engine."
)
}
fn ambiguous_message(path: &str, text: &str, old: &str) -> String {
let outcome = fuzzy::find_match(
text,
old,
&fuzzy::FindMatchOptions {
allow_fuzzy: false,
threshold: None,
excluded_ranges: &[],
},
);
let mut message = fuzzy::format_occurrence_error(path, &outcome);
message
.push_str("\nPass `replace_all: true` to replace every occurrence without disambiguating.");
message
}
fn no_match_message(path: &str, text: &str, old: &str) -> String {
let outcome = fuzzy::find_match(
text,
old,
&fuzzy::FindMatchOptions {
allow_fuzzy: true,
threshold: None,
excluded_ranges: &[],
},
);
let Some(closest) = &outcome.closest else {
return fuzzy::format_match_error(
path,
old,
None,
false,
fuzzy::DEFAULT_FUZZY_THRESHOLD,
None,
);
};
if outcome.matched.is_some() {
let percent = (closest.confidence * 100.0).round() as i64;
let search_lines: Vec<&str> = old.split('\n').collect();
let actual_lines: Vec<&str> = closest.actual_text.split('\n').collect();
let (old_line, new_line) = fuzzy::first_different_line(&search_lines, &actual_lines);
return format!(
"Could not find the exact text in {path}.\n\nA near-identical region ({percent}% similar) exists at line {}: copy its text exactly into old_string:\n - {old_line}\n + {new_line}",
closest.start_line
);
}
fuzzy::format_match_error(
path,
old,
Some(closest),
true,
fuzzy::DEFAULT_FUZZY_THRESHOLD,
outcome.fuzzy_matches,
)
}
#[cfg(test)]
mod test;