use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EditError {
pub kind: EditErrorKind,
pub message: String,
pub suggestion: Option<String>,
pub similar_targets: Vec<String>,
}
impl std::fmt::Display for EditError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.kind, self.message)?;
if let Some(ref suggestion) = self.suggestion {
write!(f, " (suggestion: {suggestion})")?;
}
if !self.similar_targets.is_empty() {
write!(f, " [similar: {}]", self.similar_targets.join(", "))?;
}
Ok(())
}
}
impl std::error::Error for EditError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EditErrorKind {
AmbiguousTarget,
NoMatch,
SyntaxInvalid,
ConflictingEdit,
ParseError,
}
impl std::fmt::Display for EditErrorKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EditErrorKind::AmbiguousTarget => write!(f, "ambiguous_target"),
EditErrorKind::NoMatch => write!(f, "no_match"),
EditErrorKind::SyntaxInvalid => write!(f, "syntax_invalid"),
EditErrorKind::ConflictingEdit => write!(f, "conflicting_edit"),
EditErrorKind::ParseError => write!(f, "parse_error"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationResult {
pub valid: bool,
pub errors: Vec<String>,
pub warnings: Vec<String>,
}
pub fn validate_edit(
content: &str,
from: &str,
to: &str,
file_path: Option<&str>,
) -> ValidationResult {
if from.is_empty() {
return ValidationResult {
valid: false,
errors: vec!["empty search pattern".into()],
warnings: vec![],
};
}
if !content.contains(from) {
return ValidationResult {
valid: false,
errors: vec![format!(
"pattern '{}' not found in content",
truncate_str(from, 60)
)],
warnings: vec![],
};
}
let new_content = content.replace(from, to);
let mut errors = Vec::new();
let mut warnings = Vec::new();
if let Some(path) = file_path {
if path.ends_with(".json") {
if let Err(e) = serde_json::from_str::<serde_json::Value>(&new_content) {
errors.push(format!("result would be invalid JSON: {e}"));
}
} else if path.ends_with(".yaml") || path.ends_with(".yml") {
if let Err(e) = serde_yaml_ng::from_str::<serde_json::Value>(&new_content) {
errors.push(format!("result would be invalid YAML: {e}"));
}
} else if path.ends_with(".toml")
&& let Err(e) = toml_edit::de::from_str::<serde_json::Value>(&new_content)
{
errors.push(format!("result would be invalid TOML: {e}"));
}
}
let open_parens =
new_content.matches('(').count() as i64 - new_content.matches(')').count() as i64;
let open_braces =
new_content.matches('{').count() as i64 - new_content.matches('}').count() as i64;
let open_brackets =
new_content.matches('[').count() as i64 - new_content.matches(']').count() as i64;
if open_parens != 0 {
warnings.push(format!("unbalanced parentheses (delta: {open_parens})"));
}
if open_braces != 0 {
warnings.push(format!("unbalanced braces (delta: {open_braces})"));
}
if open_brackets != 0 {
warnings.push(format!("unbalanced brackets (delta: {open_brackets})"));
}
ValidationResult {
valid: errors.is_empty(),
errors,
warnings,
}
}
pub fn find_similar_targets(content: &str, target: &str, max_results: usize) -> Vec<String> {
if target.is_empty() || content.is_empty() {
return vec![];
}
let mut candidates: Vec<(String, f64)> = Vec::new();
let mut seen = std::collections::HashSet::new();
for line in content.lines() {
for word in extract_identifiers(line) {
if !seen.insert(word.clone()) {
continue;
}
let score = strsim::jaro_winkler(&word, target);
if score > 0.7 && word != target {
candidates.push((word, score));
}
}
}
if target.contains(' ') || target.len() > 20 {
for line in content.lines() {
let trimmed = line.trim().to_string();
if trimmed.is_empty() || seen.contains(&trimmed) {
continue;
}
seen.insert(trimmed.clone());
let score = strsim::jaro_winkler(&trimmed, target);
if score > 0.7 && trimmed != target {
candidates.push((trimmed, score));
}
}
}
candidates.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
candidates.truncate(max_results);
candidates.into_iter().map(|(s, _)| s).collect()
}
pub fn anchor_match(
content: &str,
target: &str,
before_context: Option<&str>,
after_context: Option<&str>,
) -> Option<AnchorMatchResult> {
if target.is_empty() {
return None;
}
if let Some(pos) = content.find(target) {
return Some(AnchorMatchResult {
matched_text: target.to_string(),
start_offset: pos,
strategy: MatchStrategy::Exact,
});
}
let lines: Vec<&str> = content.lines().collect();
let target_lines: Vec<&str> = target.lines().collect();
if target_lines.is_empty() {
return None;
}
let first_target = target_lines[0].trim();
let last_target = target_lines.last().map(|l| l.trim()).unwrap_or("");
if before_context.is_none() && after_context.is_none() {
return None;
}
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if strsim::jaro_winkler(trimmed, first_target) < 0.85 {
continue;
}
if target_lines.len() > 1 {
let end_idx = i + target_lines.len();
if end_idx > lines.len() {
continue;
}
let candidate_last = lines[end_idx - 1].trim();
if strsim::jaro_winkler(candidate_last, last_target) < 0.85 {
continue;
}
}
if let Some(before) = before_context {
if i == 0 {
continue;
}
let prev = lines[i - 1].trim();
if strsim::jaro_winkler(prev, before.trim()) < 0.8 {
continue;
}
}
if let Some(after) = after_context {
let end_idx = i + target_lines.len();
if end_idx >= lines.len() {
continue;
}
let next = lines[end_idx].trim();
if strsim::jaro_winkler(next, after.trim()) < 0.8 {
continue;
}
}
let end_idx = (i + target_lines.len()).min(lines.len());
let matched: Vec<&str> = lines[i..end_idx].to_vec();
let matched_text = matched.join("\n");
let line_sep_len = if content.contains("\r\n") { 2 } else { 1 };
let start_offset = lines[..i]
.iter()
.map(|l| l.len() + line_sep_len)
.sum::<usize>();
return Some(AnchorMatchResult {
matched_text,
start_offset,
strategy: MatchStrategy::Anchor,
});
}
None
}
#[derive(Debug, Clone)]
pub struct AnchorMatchResult {
pub matched_text: String,
pub start_offset: usize,
pub strategy: MatchStrategy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MatchStrategy {
Exact,
Anchor,
Similarity,
}
impl std::fmt::Display for MatchStrategy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MatchStrategy::Exact => write!(f, "exact"),
MatchStrategy::Anchor => write!(f, "anchor"),
MatchStrategy::Similarity => write!(f, "similarity"),
}
}
}
pub fn resolve_with_fallback(
content: &str,
target: &str,
before_context: Option<&str>,
after_context: Option<&str>,
) -> Result<AnchorMatchResult, EditError> {
if let Some(pos) = content.find(target) {
return Ok(AnchorMatchResult {
matched_text: target.to_string(),
start_offset: pos,
strategy: MatchStrategy::Exact,
});
}
if let Some(result) = anchor_match(content, target, before_context, after_context) {
return Ok(result);
}
let target_lines: Vec<&str> = target.lines().collect();
if target_lines.len() == 1 {
let mut best_score = 0.0f64;
let mut best_match = String::new();
let mut best_offset = 0usize;
let mut offset = 0usize;
for line in content.lines() {
let score = strsim::jaro_winkler(line.trim(), target.trim());
if score > best_score {
best_score = score;
best_match = line.to_string();
best_offset = offset;
}
offset += line.len() + 1;
}
if best_score > 0.85 {
return Ok(AnchorMatchResult {
matched_text: best_match,
start_offset: best_offset,
strategy: MatchStrategy::Similarity,
});
}
}
let similar = find_similar_targets(content, target.lines().next().unwrap_or(target), 5);
let suggestion = if !similar.is_empty() {
Some(format!("did you mean: {}?", similar[0]))
} else {
None
};
Err(EditError {
kind: EditErrorKind::NoMatch,
message: format!("target not found: '{}'", truncate_str(target, 80)),
suggestion,
similar_targets: similar,
})
}
fn extract_identifiers(line: &str) -> Vec<String> {
let mut identifiers = Vec::new();
let mut current = String::new();
for ch in line.chars() {
if ch.is_alphanumeric() || ch == '_' {
current.push(ch);
} else {
if current.len() >= 3 {
identifiers.push(std::mem::take(&mut current));
} else {
current.clear();
}
}
}
if current.len() >= 3 {
identifiers.push(current);
}
identifiers
}
pub(crate) fn truncate_str(s: &str, max_len: usize) -> &str {
if s.len() <= max_len {
s
} else {
let mut end = max_len;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn edit_error_display() {
let err = EditError {
kind: EditErrorKind::NoMatch,
message: "target not found".into(),
suggestion: Some("try 'process_request'".into()),
similar_targets: vec!["process_request".into()],
};
let display = err.to_string();
assert!(display.contains("no_match"));
assert!(display.contains("target not found"));
assert!(display.contains("process_request"));
}
#[test]
fn edit_error_kind_display() {
assert_eq!(
EditErrorKind::AmbiguousTarget.to_string(),
"ambiguous_target"
);
assert_eq!(EditErrorKind::NoMatch.to_string(), "no_match");
assert_eq!(EditErrorKind::SyntaxInvalid.to_string(), "syntax_invalid");
assert_eq!(
EditErrorKind::ConflictingEdit.to_string(),
"conflicting_edit"
);
assert_eq!(EditErrorKind::ParseError.to_string(), "parse_error");
}
#[test]
fn validate_edit_empty_pattern() {
let result = validate_edit("content", "", "replacement", None);
assert!(!result.valid);
assert!(result.errors[0].contains("empty search pattern"));
}
#[test]
fn validate_edit_pattern_not_found() {
let result = validate_edit("hello world", "missing", "replacement", None);
assert!(!result.valid);
assert!(result.errors[0].contains("not found"));
}
#[test]
fn validate_edit_valid_replacement() {
let result = validate_edit("hello world", "hello", "goodbye", None);
assert!(result.valid);
assert!(result.errors.is_empty());
}
#[test]
fn validate_edit_json_syntax_check() {
let json = r#"{"key": "value"}"#;
let result = validate_edit(json, "value", "new_value", Some("config.json"));
assert!(result.valid);
let result = validate_edit(json, "\"key\":", "broken", Some("config.json"));
assert!(!result.valid);
assert!(result.errors[0].contains("invalid JSON"));
}
#[test]
fn validate_edit_yaml_syntax_check() {
let yaml = "key: value\n";
let result = validate_edit(yaml, "value", "new_value", Some("config.yaml"));
assert!(result.valid);
}
#[test]
fn validate_edit_warns_unbalanced_braces() {
let content = "fn main() { hello }";
let result = validate_edit(content, "{ hello }", "{ hello", None);
assert!(result.valid); assert!(
result
.warnings
.iter()
.any(|w| w.contains("unbalanced braces"))
);
}
#[test]
fn find_similar_targets_finds_typos() {
let content = "fn process_request() {}\nfn process_response() {}\nfn handle_error() {}\n";
let similar = find_similar_targets(content, "process_requst", 3);
assert!(!similar.is_empty());
assert!(similar.iter().any(|s| s.contains("process_request")));
}
#[test]
fn find_similar_targets_empty_content() {
let similar = find_similar_targets("", "target", 3);
assert!(similar.is_empty());
}
#[test]
fn find_similar_targets_empty_target() {
let similar = find_similar_targets("content", "", 3);
assert!(similar.is_empty());
}
#[test]
fn anchor_match_exact() {
let content = "line1\nline2\nline3\n";
let result = anchor_match(content, "line2", None, None).unwrap();
assert_eq!(result.matched_text, "line2");
assert_eq!(result.strategy, MatchStrategy::Exact);
}
#[test]
fn anchor_match_with_context() {
let content = "fn setup() {}\nfn proccess_data(x: i32) {}\nfn cleanup() {}\n";
let result = anchor_match(
content,
"fn process_data(x: i32) {}",
Some("fn setup() {}"),
Some("fn cleanup() {}"),
);
let r = result.expect("anchor match should find a fuzzy match");
assert_eq!(r.strategy, MatchStrategy::Anchor);
assert!(r.matched_text.contains("proccess_data"));
}
#[test]
fn anchor_match_no_match() {
let content = "completely different content\n";
let result = anchor_match(content, "not here at all", None, None);
assert!(result.is_none());
}
#[test]
fn anchor_match_requires_context_for_fuzzy() {
let content = "fn process_request(x: i32) {}\n";
let result = anchor_match(content, "fn process_requst(x: i32) {}", None, None);
assert!(result.is_none());
}
#[test]
fn anchor_match_multi_line_verifies_last_line() {
let content = "fn setup() {}\nfn process(x: i32) {}\nfn teardown() {}\nfn other() {}\n";
let result = anchor_match(
content,
"fn process(x: i32) {}\nfn completely_wrong() {}",
Some("fn setup() {}"),
None,
);
assert!(result.is_none());
}
#[test]
fn anchor_match_crlf_offset() {
let content = "line1\r\nline2\r\nline3\r\n";
let result = anchor_match(content, "line2", Some("line1"), None).unwrap();
assert_eq!(result.start_offset, 7);
}
#[test]
fn resolve_with_fallback_exact_match() {
let content = "fn hello() {}\n";
let result = resolve_with_fallback(content, "fn hello()", None, None).unwrap();
assert_eq!(result.strategy, MatchStrategy::Exact);
}
#[test]
fn resolve_with_fallback_similarity_match() {
let content = "fn process_request(data: &str) -> Result<()> {\n Ok(())\n}\n";
let result = resolve_with_fallback(
content,
"fn process_requets(data: &str) -> Result<()> {",
None,
None,
);
assert!(result.is_ok());
let r = result.unwrap();
assert_eq!(r.strategy, MatchStrategy::Similarity);
}
#[test]
fn resolve_with_fallback_structured_error() {
let content = "fn alpha() {}\nfn beta() {}\n";
let result = resolve_with_fallback(content, "fn completely_unrelated_xyz()", None, None);
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind, EditErrorKind::NoMatch);
assert!(err.message.contains("target not found"));
}
#[test]
fn resolve_with_fallback_anchor_over_similarity() {
let content = "fn setup() {}\nfn proces_data(x: i32) {}\nfn cleanup() {}\n";
let result = resolve_with_fallback(
content,
"fn process_data(x: i32) {}",
Some("fn setup() {}"),
Some("fn cleanup() {}"),
);
assert!(result.is_ok());
let r = result.unwrap();
assert_eq!(r.strategy, MatchStrategy::Anchor);
}
#[test]
fn edit_error_serializes_to_json() {
let err = EditError {
kind: EditErrorKind::AmbiguousTarget,
message: "found 3 matches".into(),
suggestion: Some("use --nth to select one".into()),
similar_targets: vec!["match1".into(), "match2".into()],
};
let json = serde_json::to_string(&err).unwrap();
let deserialized: EditError = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.kind, EditErrorKind::AmbiguousTarget);
assert_eq!(deserialized.similar_targets.len(), 2);
}
#[test]
fn match_strategy_display() {
assert_eq!(MatchStrategy::Exact.to_string(), "exact");
assert_eq!(MatchStrategy::Anchor.to_string(), "anchor");
assert_eq!(MatchStrategy::Similarity.to_string(), "similarity");
}
#[test]
fn extract_identifiers_from_code() {
let line = "fn process_request(data: &str) -> Result<()> {";
let ids = extract_identifiers(line);
assert!(ids.contains(&"process_request".to_string()));
assert!(ids.contains(&"data".to_string()));
assert!(ids.contains(&"str".to_string()));
assert!(ids.contains(&"Result".to_string()));
}
#[test]
fn validation_result_serializes() {
let result = ValidationResult {
valid: true,
errors: vec![],
warnings: vec!["some warning".into()],
};
let json = serde_json::to_string(&result).unwrap();
let deserialized: ValidationResult = serde_json::from_str(&json).unwrap();
assert!(deserialized.valid);
assert_eq!(deserialized.warnings.len(), 1);
}
#[test]
fn truncate_safe_on_multibyte_utf8() {
let s = "café";
assert_eq!(s.len(), 5);
let t = truncate_str(s, 4);
assert_eq!(t, "caf");
assert_eq!(truncate_str(s, 5), "café");
assert_eq!(truncate_str(s, 3), "caf");
assert_eq!(truncate_str(s, 0), "");
}
#[test]
fn validate_edit_toml_syntax_check() {
let toml = "[database]\nhost = \"localhost\"\n";
let result = validate_edit(toml, "localhost", "remotehost", Some("config.toml"));
assert!(result.valid);
let result = validate_edit(
toml,
"[database]",
"not valid toml {{{{",
Some("config.toml"),
);
assert!(!result.valid);
assert!(result.errors[0].contains("invalid TOML"));
}
#[test]
fn find_similar_targets_multi_word_pattern() {
let content = "fn process_all_requests(data: &str) -> bool {\n true\n}\n";
let similar = find_similar_targets(content, "fn process_all_reqests(data: &str)", 3);
assert!(
!similar.is_empty(),
"should find similar multi-word targets"
);
}
#[test]
fn resolve_fallback_multi_line_no_similarity() {
let content = "fn alpha() {}\nfn beta() {}\nfn gamma() {}\n";
let result = resolve_with_fallback(content, "fn alphax() {}\nfn betax() {}", None, None);
assert!(
result.is_err(),
"multi-line targets without context should not match via similarity"
);
let err = result.unwrap_err();
assert_eq!(err.kind, EditErrorKind::NoMatch);
}
#[test]
fn resolve_with_fallback_multi_line_anchor_match() {
let content = "fn header() {}\nfn proccess_data(x: i32) {\n x + 1\n}\nfn footer() {}\n";
let result = resolve_with_fallback(
content,
"fn process_data(x: i32) {\n x + 1\n}",
Some("fn header() {}"),
Some("fn footer() {}"),
);
assert!(result.is_ok(), "multi-line anchor should succeed");
let r = result.unwrap();
assert_eq!(r.strategy, MatchStrategy::Anchor);
assert!(r.matched_text.contains("proccess_data"));
assert!(r.matched_text.contains("x + 1"));
}
#[test]
fn validate_edit_yml_extension() {
let yaml = "key: value\nlist:\n - item1\n";
let result = validate_edit(yaml, "value", "new_value", Some("config.yml"));
assert!(result.valid);
let result = validate_edit(yaml, "key: value", ":\n :\n - :", Some("config.yml"));
assert!(
!result.valid,
".yml extension should trigger YAML validation"
);
assert!(
result.errors[0].contains("invalid YAML"),
"expected YAML error, got: {}",
result.errors[0]
);
}
const _: () = {
fn _assert<T: Send + Sync>() {}
let _ = _assert::<EditError>;
let _ = _assert::<EditErrorKind>;
let _ = _assert::<ValidationResult>;
let _ = _assert::<AnchorMatchResult>;
let _ = _assert::<MatchStrategy>;
};
}