use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
pub struct AssistantDocumentEditOp {
pub old_string: String,
pub new_string: String,
}
#[derive(thiserror::Error, Clone, Debug, PartialEq, Eq)]
pub enum AssistantDocumentEditError {
#[error(
"edit {ordinal}: `old_string` is empty — an empty match names no place in the document. \
Quote the exact bytes to replace."
)]
EmptyOldString {
ordinal: usize,
},
#[error(
"edit {ordinal}: `old_string` was not found in the document (searched for {preview:?}). \
The document may have changed since it was read — read it again and re-quote the exact \
bytes."
)]
Absent {
ordinal: usize,
preview: String,
},
#[error(
"edit {ordinal}: `old_string` occurs {matches} times (searched for {preview:?}), so it \
does not say which occurrence to replace. Quote more surrounding text to make it unique."
)]
Ambiguous {
ordinal: usize,
matches: usize,
preview: String,
},
}
const PREVIEW_CHARACTERS: usize = 80;
pub fn apply_document_edits(
text: &str,
edits: &[AssistantDocumentEditOp],
) -> Result<String, AssistantDocumentEditError> {
let mut current = text.to_owned();
for (index, edit) in edits.iter().enumerate() {
let ordinal = index.saturating_add(1);
if edit.old_string.is_empty() {
return Err(AssistantDocumentEditError::EmptyOldString { ordinal });
}
match current.matches(edit.old_string.as_str()).count() {
0 => {
return Err(AssistantDocumentEditError::Absent {
ordinal,
preview: preview_of(&edit.old_string),
});
}
1 => {
current = current.replacen(edit.old_string.as_str(), &edit.new_string, 1);
}
matches => {
return Err(AssistantDocumentEditError::Ambiguous {
ordinal,
matches,
preview: preview_of(&edit.old_string),
});
}
}
}
Ok(current)
}
fn preview_of(searched: &str) -> String {
searched.chars().take(PREVIEW_CHARACTERS).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn op(old: &str, new: &str) -> AssistantDocumentEditOp {
AssistantDocumentEditOp {
old_string: old.to_owned(),
new_string: new.to_owned(),
}
}
#[test]
fn edits_apply_in_order_against_the_running_text() -> Result<(), AssistantDocumentEditError> {
let applied = apply_document_edits(
"workflow demo\nstep one\n",
&[op("step one", "step first"), op("first", "first_renamed")],
)?;
assert_eq!(applied, "workflow demo\nstep first_renamed\n");
Ok(())
}
#[test]
fn an_absent_old_string_refuses_the_whole_batch() {
let refused = apply_document_edits(
"workflow demo\n",
&[op("workflow demo", "workflow renamed"), op("gone", "there")],
);
assert_eq!(
refused,
Err(AssistantDocumentEditError::Absent {
ordinal: 2,
preview: "gone".to_owned(),
}),
"the batch is atomic: the first edit matched, and none of it lands"
);
}
#[test]
fn an_ambiguous_old_string_is_refused_with_its_count() {
let refused = apply_document_edits("a b a", &[op("a", "c")]);
assert_eq!(
refused,
Err(AssistantDocumentEditError::Ambiguous {
ordinal: 1,
matches: 2,
preview: "a".to_owned(),
})
);
}
#[test]
fn an_empty_old_string_is_refused_by_name() {
let refused = apply_document_edits("text", &[op("", "inserted")]);
assert_eq!(
refused,
Err(AssistantDocumentEditError::EmptyOldString { ordinal: 1 })
);
}
#[test]
fn a_long_search_string_is_previewed_on_character_boundaries() -> Result<(), String> {
let long = "é".repeat(200);
let refused = apply_document_edits("text", &[op(&long, "x")]);
match refused {
Err(AssistantDocumentEditError::Absent { preview, .. }) => {
assert_eq!(preview.chars().count(), 80);
Ok(())
}
other => Err(format!("expected an Absent refusal, got {other:?}")),
}
}
#[test]
fn strings_are_literal_bytes_not_patterns() -> Result<(), AssistantDocumentEditError> {
let applied = apply_document_edits("route a|b", &[op("a|b", "a.b")])?;
assert_eq!(applied, "route a.b");
Ok(())
}
#[test]
fn overlapping_candidates_count_as_disjoint_matches() -> Result<(), AssistantDocumentEditError>
{
let applied = apply_document_edits("a\n\n\nb", &[op("\n\n", "\n")])?;
assert_eq!(applied, "a\n\nb");
let applied = apply_document_edits("aaa", &[op("aa", "x")])?;
assert_eq!(applied, "xa");
Ok(())
}
}