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)]
#[non_exhaustive]
#[serde(rename_all = "snake_case")]
pub enum EditErrorKind {
AmbiguousTarget,
NoMatch,
SyntaxInvalid,
ConflictingEdit,
ParseError,
GuardRejected,
InvalidInput,
TypeError,
OperationFailed,
FormatFailed,
}
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"),
EditErrorKind::GuardRejected => write!(f, "guard_rejected"),
EditErrorKind::InvalidInput => write!(f, "invalid_input"),
EditErrorKind::TypeError => write!(f, "type_error"),
EditErrorKind::OperationFailed => write!(f, "operation_failed"),
EditErrorKind::FormatFailed => write!(f, "format_failed"),
}
}
}
impl EditError {
pub fn new(kind: EditErrorKind, message: impl Into<String>) -> Self {
Self {
kind,
message: message.into(),
suggestion: None,
similar_targets: Vec::new(),
}
}
pub fn with_similar(mut self, similar: Vec<String>) -> Self {
self.similar_targets = similar;
self
}
pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
self.suggestion = Some(suggestion.into());
self
}
}
pub fn edit_error_kind(err: &anyhow::Error) -> Option<EditErrorKind> {
for cause in err.chain() {
if let Some(kind) = classify_error(cause) {
return Some(kind);
}
}
None
}
pub fn edit_error_ref(err: &anyhow::Error) -> Option<&EditError> {
for cause in err.chain() {
if let Some(e) = classify_error_ref(cause) {
return Some(e);
}
}
None
}
pub fn classify_error(err: &(dyn std::error::Error + 'static)) -> Option<EditErrorKind> {
let mut current: Option<&(dyn std::error::Error + 'static)> = Some(err);
while let Some(e) = current {
if let Some(edit) = e.downcast_ref::<EditError>() {
return Some(edit.kind);
}
if e.downcast_ref::<crate::exit::NoMatchError>().is_some() {
return Some(EditErrorKind::NoMatch);
}
if e.downcast_ref::<crate::exit::AmbiguousError>().is_some() {
return Some(EditErrorKind::AmbiguousTarget);
}
if e.downcast_ref::<crate::exit::InvalidInputError>().is_some()
|| e.downcast_ref::<crate::exit::AlreadyExistsError>()
.is_some()
{
return Some(EditErrorKind::InvalidInput);
}
if e.downcast_ref::<crate::exit::TypeErrorError>().is_some() {
return Some(EditErrorKind::TypeError);
}
if e.downcast_ref::<crate::exit::ParseErrorError>().is_some() {
return Some(EditErrorKind::ParseError);
}
if e.downcast_ref::<crate::exit::FormatFailedError>().is_some() {
return Some(EditErrorKind::FormatFailed);
}
if e.downcast_ref::<crate::exit::ConflictsError>().is_some()
|| e.downcast_ref::<crate::exit::ChangesDetectedError>()
.is_some()
{
return Some(EditErrorKind::OperationFailed);
}
if e.downcast_ref::<std::io::Error>()
.is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
{
return Some(EditErrorKind::OperationFailed);
}
current = e.source();
}
None
}
pub fn classify_error_ref<'a>(err: &'a (dyn std::error::Error + 'static)) -> Option<&'a EditError> {
let mut current: Option<&'a (dyn std::error::Error + 'static)> = Some(err);
while let Some(e) = current {
if let Some(edit) = e.downcast_ref::<EditError>() {
return Some(edit);
}
current = e.source();
}
None
}
#[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 {
validate_edit_nth(content, from, to, file_path, None)
}
pub fn validate_edit_nth(
content: &str,
from: &str,
to: &str,
file_path: Option<&str>,
nth: Option<usize>,
) -> 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 = match nth {
Some(0) => {
return ValidationResult {
valid: false,
errors: vec!["nth must be >= 1 (1-based indexing)".into()],
warnings: vec![],
};
}
Some(n) => {
let mut count = 0usize;
let mut result = String::with_capacity(content.len());
let mut remaining = content;
while let Some(pos) = remaining.find(from) {
count += 1;
if count == n {
result.push_str(&remaining[..pos]);
result.push_str(to);
result.push_str(&remaining[pos + from.len()..]);
break;
}
result.push_str(&remaining[..pos + from.len()]);
remaining = &remaining[pos + from.len()..];
}
if count < n {
return ValidationResult {
valid: false,
errors: vec![format!(
"occurrence {n} not found (only {count} occurrence{} exist{})",
if count == 1 { "" } else { "s" },
if count == 1 { "s" } else { "" },
)],
warnings: vec![],
};
}
result
}
None => content.replace(from, to),
};
let mut errors = Vec::new();
let mut warnings = Vec::new();
if let Some(path) = file_path
&& let Ok(fmt) = crate::ops::doc::detect_format(path)
{
let parse_err = match fmt {
crate::ops::doc::FileFormat::Json => {
serde_json::from_str::<serde_json::Value>(&new_content)
.err()
.map(|e| format!("result would be invalid JSON: {e}"))
}
crate::ops::doc::FileFormat::Yaml => {
serde_yaml_ng::from_str::<serde_json::Value>(&new_content)
.err()
.map(|e| format!("result would be invalid YAML: {e}"))
}
crate::ops::doc::FileFormat::Toml => {
toml_edit::de::from_str::<serde_json::Value>(&new_content)
.err()
.map(|e| format!("result would be invalid TOML: {e}"))
}
};
if let Some(msg) = parse_err {
errors.push(msg);
}
}
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,
score: None,
});
}
let lines: Vec<&str> = content.lines().collect();
let target_lines: Vec<&str> = target.lines().collect();
let mut line_byte_starts: Vec<usize> = vec![0];
for (i, b) in content.bytes().enumerate() {
if b == b'\n' {
line_byte_starts.push(i + 1);
}
}
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();
let before_line = before.lines().last().unwrap_or(before).trim();
if strsim::jaro_winkler(prev, before_line) < 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();
let after_line = after.lines().next().unwrap_or(after).trim();
if strsim::jaro_winkler(next, after_line) < 0.8 {
continue;
}
}
let end_idx = (i + target_lines.len()).min(lines.len());
let start_offset = line_byte_starts[i];
let end_offset = if end_idx < line_byte_starts.len() {
line_byte_starts[end_idx]
} else {
content.len()
};
let slice = &content[start_offset..end_offset];
let matched_text = slice
.strip_suffix("\r\n")
.or_else(|| slice.strip_suffix('\n'))
.unwrap_or(slice)
.to_string();
return Some(AnchorMatchResult {
matched_text,
start_offset,
strategy: MatchStrategy::Anchor,
score: None,
});
}
None
}
#[derive(Debug, Clone)]
pub struct AnchorMatchResult {
pub matched_text: String,
pub start_offset: usize,
pub strategy: MatchStrategy,
pub score: Option<f64>,
}
#[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> {
resolve_with_fallback_skip_exact(content, target, before_context, after_context, false)
}
pub fn resolve_with_fallback_skip_exact(
content: &str,
target: &str,
before_context: Option<&str>,
after_context: Option<&str>,
skip_exact: bool,
) -> Result<AnchorMatchResult, EditError> {
if !skip_exact && let Some(pos) = content.find(target) {
return Ok(AnchorMatchResult {
matched_text: target.to_string(),
start_offset: pos,
strategy: MatchStrategy::Exact,
score: None,
});
}
if let Some(result) = anchor_match(content, target, before_context, after_context) {
if !(skip_exact && result.strategy == MatchStrategy::Exact) {
return Ok(result);
}
}
let target_lines: Vec<&str> = target.lines().collect();
if target_lines.len() == 1 {
let target_trim = target.trim();
if is_token_like_target(target_trim) {
if let Some(hit) = best_token_similarity(content, target_trim) {
return Ok(hit);
}
} else if let Some(hit) = best_line_similarity(content, target_trim) {
return Ok(hit);
}
}
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,
})
}
pub(crate) fn fuzzy_fails_min_floor(score: Option<f64>, min: f64) -> bool {
match score {
Some(s) => s < min,
None => true,
}
}
pub(crate) fn should_refuse_fuzzy_absent_old(
fuzzy_requested: bool,
is_fuzzy_mode: bool,
allow_absent_old: bool,
) -> bool {
fuzzy_requested && is_fuzzy_mode && !allow_absent_old
}
pub(crate) fn fuzzy_absent_old_refuse_message(
old: &str,
matched_text: &str,
score: Option<f64>,
) -> String {
let score_s = score
.map(|s| format!("{s:.3}"))
.unwrap_or_else(|| "none".into());
format!(
"exact old absent for {:?}; best fuzzy candidate {:?} score {} \
(set allow_absent_old / --allow-absent-old to apply)",
truncate_str(old, 60),
truncate_str(matched_text, 60),
score_s
)
}
fn is_token_like_target(target: &str) -> bool {
if target.is_empty() || target.chars().any(|c| c.is_whitespace()) {
return false;
}
target.chars().any(|c| c.is_alphanumeric())
&& target.chars().all(|c| c.is_alphanumeric() || c == '_')
}
fn best_token_similarity(content: &str, target: &str) -> Option<AnchorMatchResult> {
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() {
for (ident, col) in extract_identifiers_with_offsets(line) {
if ident == target {
continue;
}
let score = strsim::jaro_winkler(&ident, target);
if score > best_score {
best_score = score;
best_match = ident;
best_offset = offset + col;
}
}
offset = advance_line_offset(content, offset, line);
}
if best_score > 0.85 {
Some(AnchorMatchResult {
matched_text: best_match,
start_offset: best_offset,
strategy: MatchStrategy::Similarity,
score: Some(best_score),
})
} else {
None
}
}
fn best_line_similarity(content: &str, target: &str) -> Option<AnchorMatchResult> {
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);
if score > best_score {
best_score = score;
best_match = line.to_string();
best_offset = offset;
}
offset = advance_line_offset(content, offset, line);
}
if best_score <= 0.85 {
return None;
}
let line_len = best_match.trim().len();
let target_len = target.len().max(1);
if line_len > target_len.saturating_mul(2) && line_len > target_len + 16 {
return None;
}
Some(AnchorMatchResult {
matched_text: best_match,
start_offset: best_offset,
strategy: MatchStrategy::Similarity,
score: Some(best_score),
})
}
fn advance_line_offset(content: &str, mut offset: usize, line: &str) -> usize {
offset += line.len();
if content.as_bytes().get(offset) == Some(&b'\r') {
offset += 1;
}
if content.as_bytes().get(offset) == Some(&b'\n') {
offset += 1;
}
offset
}
fn extract_identifiers(line: &str) -> Vec<String> {
extract_identifiers_with_offsets(line)
.into_iter()
.map(|(s, _)| s)
.collect()
}
fn extract_identifiers_with_offsets(line: &str) -> Vec<(String, usize)> {
let mut identifiers = Vec::new();
let mut current = String::new();
let mut start = 0usize;
let mut i = 0usize;
for ch in line.chars() {
let clen = ch.len_utf8();
if ch.is_alphanumeric() || ch == '_' {
if current.is_empty() {
start = i;
}
current.push(ch);
} else {
if current.len() >= 3 {
identifiers.push((std::mem::take(&mut current), start));
} else {
current.clear();
}
}
i += clen;
}
if current.len() >= 3 {
identifiers.push((current, start));
}
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");
assert_eq!(EditErrorKind::TypeError.to_string(), "type_error");
assert_eq!(EditErrorKind::InvalidInput.to_string(), "invalid_input");
assert_eq!(EditErrorKind::FormatFailed.to_string(), "format_failed");
}
#[test]
fn classify_error_peels_edit_error_and_typed() {
use std::error::Error;
let e = EditError::new(EditErrorKind::AmbiguousTarget, "many");
assert_eq!(
classify_error(&e as &(dyn Error + 'static)),
Some(EditErrorKind::AmbiguousTarget)
);
let e = crate::exit::NoMatchError { msg: "none".into() };
assert_eq!(
classify_error(&e as &(dyn Error + 'static)),
Some(EditErrorKind::NoMatch)
);
let e = crate::exit::FormatFailedError::new("fmt");
assert_eq!(
classify_error(&e as &(dyn Error + 'static)),
Some(EditErrorKind::FormatFailed)
);
let e = crate::exit::TypeErrorError {
msg: "parent is an array".into(),
};
assert_eq!(
classify_error(&e as &(dyn Error + 'static)),
Some(EditErrorKind::TypeError)
);
}
#[test]
fn edit_error_kind_maps_exit_typed_errors() {
let invalid: anyhow::Error = crate::exit::InvalidInputError {
msg: "empty search pattern".into(),
}
.into();
assert_eq!(edit_error_kind(&invalid), Some(EditErrorKind::InvalidInput));
let no_match: anyhow::Error = crate::exit::NoMatchError {
msg: "no matches".into(),
}
.into();
assert_eq!(edit_error_kind(&no_match), Some(EditErrorKind::NoMatch));
let ambiguous: anyhow::Error = crate::exit::AmbiguousError {
msg: "multiple matches".into(),
}
.into();
assert_eq!(
edit_error_kind(&ambiguous),
Some(EditErrorKind::AmbiguousTarget)
);
let parse: anyhow::Error = crate::exit::ParseErrorError {
msg: "bad yaml".into(),
}
.into();
assert_eq!(edit_error_kind(&parse), Some(EditErrorKind::ParseError));
let exists: anyhow::Error = crate::exit::AlreadyExistsError {
msg: "file already exists".into(),
}
.into();
assert_eq!(edit_error_kind(&exists), Some(EditErrorKind::InvalidInput));
let type_err: anyhow::Error = crate::exit::TypeErrorError {
msg: "parent is an array, not an object".into(),
}
.into();
assert_eq!(
edit_error_kind(&type_err),
Some(EditErrorKind::TypeError),
"TypeErrorError must not collapse to InvalidInput (#1883)"
);
let format_failed: anyhow::Error =
crate::exit::FormatFailedError::new("format command failed").into();
assert_eq!(
edit_error_kind(&format_failed),
Some(EditErrorKind::FormatFailed)
);
let not_found: anyhow::Error =
std::io::Error::new(std::io::ErrorKind::NotFound, "missing").into();
let not_found = not_found.context("failed to read path");
assert_eq!(
edit_error_kind(¬_found),
Some(EditErrorKind::OperationFailed),
"IO NotFound should peel via classify not_found"
);
let wrapped = invalid.context("operation 1 (replace) failed");
assert_eq!(edit_error_kind(&wrapped), Some(EditErrorKind::InvalidInput));
let type_wrapped = type_err.context("operation 1 (doc.set) failed");
assert_eq!(
edit_error_kind(&type_wrapped),
Some(EditErrorKind::TypeError)
);
let plain = anyhow::anyhow!("plain error");
assert_eq!(edit_error_kind(&plain), None);
}
#[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 anchor_match_crlf_matched_text_preserves_endings() {
let content =
"fn setup() {}\r\nfn proccess_data(x: i32) {}\r\nfn more() {}\r\nfn cleanup() {}\r\n";
let result = anchor_match(
content,
"fn process_data(x: i32) {}\nfn more() {}",
Some("fn setup() {}"),
Some("fn cleanup() {}"),
)
.unwrap();
let end = result.start_offset + result.matched_text.len();
assert_eq!(
&content[result.start_offset..end],
result.matched_text,
"matched_text must be an exact slice of the original content"
);
assert!(
result.matched_text.contains("\r\n"),
"matched text should preserve CRLF line endings"
);
}
#[test]
fn anchor_match_mixed_endings_correct_offset() {
let content = "header\r\nfn proccess(x: i32) {}\nfooter\n";
let result = anchor_match(
content,
"fn process(x: i32) {}",
Some("header"),
Some("footer"),
)
.unwrap();
assert_eq!(result.start_offset, 8);
let end = result.start_offset + result.matched_text.len();
assert_eq!(&content[result.start_offset..end], result.matched_text);
}
#[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_skip_exact_rejects_substring() {
let content = "process_data process_data_extra\n";
let bare = resolve_with_fallback(content, "process_dat", None, None).unwrap();
assert_eq!(bare.strategy, MatchStrategy::Exact);
assert_eq!(bare.matched_text, "process_dat");
match resolve_with_fallback_skip_exact(content, "process_dat", None, None, true) {
Ok(hit) => {
assert_ne!(
hit.strategy,
MatchStrategy::Exact,
"skip_exact must not return Exact"
);
assert_ne!(
hit.matched_text, "process_dat",
"must not re-accept word_boundary-rejected substring"
);
}
Err(e) => assert_eq!(e.kind, EditErrorKind::NoMatch),
}
}
#[test]
fn resolve_with_fallback_similarity_crlf_offset() {
let content = "fn alpha() {}\r\nfn process_requets(data: &str) {}\r\nfn gamma() {}\r\n";
let result =
resolve_with_fallback(content, "fn process_requests(data: &str) {}", None, None)
.unwrap();
assert_eq!(result.strategy, MatchStrategy::Similarity);
assert_eq!(result.start_offset, 15);
assert!(
content[result.start_offset..].starts_with(&result.matched_text),
"start_offset must point to matched_text in content"
);
}
#[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,
);
let r = result.expect("similarity fallback should succeed");
assert_eq!(r.strategy, MatchStrategy::Similarity);
assert!(
r.matched_text.contains("process_request"),
"snippet match: {:?}",
r.matched_text
);
}
#[test]
fn resolve_token_typo_does_not_match_whole_line() {
let content = "const CONFIGURATION_VALUE_PRIMARY: i32 = 1;\nfn use_it() -> i32 { CONFIGURATION_VALUE_PRIMARY }\n";
let r = resolve_with_fallback(content, "CONFIGURATION_VALUE_PRIMRY", None, None)
.expect("token typo should fuzzy-match the identifier");
assert_eq!(r.strategy, MatchStrategy::Similarity);
assert_eq!(
r.matched_text, "CONFIGURATION_VALUE_PRIMARY",
"must match the identifier token only, not the whole line"
);
assert!(
content[r.start_offset..].starts_with("CONFIGURATION_VALUE_PRIMARY"),
"offset must point at the token"
);
assert!(!r.matched_text.contains("const"));
assert!(!r.matched_text.contains("i32"));
}
#[test]
fn resolve_full_line_snippet_still_matches_line() {
let content = "const FOO: i32 = 1;\n";
let r = resolve_with_fallback(content, "const FO: i32 = 1;", None, None)
.expect("near-full-line snippet should match the line");
assert_eq!(r.strategy, MatchStrategy::Similarity);
assert!(
r.matched_text.contains("const") && r.matched_text.contains("FOO"),
"line match: {:?}",
r.matched_text
);
}
#[test]
fn resolve_token_typo_matrix_preserves_non_identifier_syntax() {
let cases: &[(&str, &str, &str, &[&str])] = &[
(
"const CONFIGURATION_VALUE_PRIMARY: i32 = 1;\nfn use_it() -> i32 { CONFIGURATION_VALUE_PRIMARY }\n",
"CONFIGURATION_VALUE_PRIMRY",
"CONFIGURATION_VALUE_PRIMARY",
&["const", "i32", "fn"],
),
(
"fn process_request(data: &str) -> Result<()> {\n Ok(())\n}\n",
"process_requets",
"process_request",
&["fn", "Result", "data"],
),
(
"def load_configuration_value():\n return 1\n",
"load_configration_value",
"load_configuration_value",
&["def", "return"],
),
(
"const getUserProfile = () => null;\nexport { getUserProfile };\n",
"getUserProfle",
"getUserProfile",
&["const", "export"],
),
(
"server_port_primary: 8080\n",
"server_port_primry",
"server_port_primary",
&[":", "8080"],
),
(
" obj.fetchUserDetails(id);\n",
"fetchUserDetials",
"fetchUserDetails",
&["obj", "id"],
),
];
for (content, typo, expected, forbidden) in cases {
let r = resolve_with_fallback(content, typo, None, None)
.unwrap_or_else(|e| panic!("token typo {typo:?} should match in {content:?}: {e}"));
assert_eq!(r.strategy, MatchStrategy::Similarity, "typo={typo:?}");
assert_eq!(
r.matched_text, *expected,
"typo={typo:?} content={content:?}"
);
assert!(
content[r.start_offset..].starts_with(expected),
"offset wrong for {typo:?}"
);
for bad in *forbidden {
assert!(
!r.matched_text.contains(bad),
"span leaked {bad:?} for typo={typo:?} match={:?}",
r.matched_text
);
}
assert!(
!r.matched_text.contains(' '),
"token match must not contain spaces: {:?}",
r.matched_text
);
}
}
#[test]
fn resolve_token_typo_picks_first_best_occurrence() {
let content = "FOO_PRIMARY=1\nFOO_PRIMARY=2\n";
let r = resolve_with_fallback(content, "FOO_PRIMRY", None, None).unwrap();
assert_eq!(r.matched_text, "FOO_PRIMARY");
assert_eq!(r.start_offset, 0);
}
#[test]
fn resolve_token_unrelated_is_no_match() {
let content = "const ALPHA: i32 = 1;\nconst BETA: i32 = 2;\n";
let err =
resolve_with_fallback(content, "ZZZZ_COMPLETELY_UNRELATED", None, None).unwrap_err();
assert_eq!(err.kind, EditErrorKind::NoMatch);
}
#[test]
fn is_token_like_target_classification() {
assert!(is_token_like_target("CONFIGURATION_VALUE_PRIMRY"));
assert!(is_token_like_target("getUserProfile"));
assert!(!is_token_like_target("kebab-case-name"));
assert!(!is_token_like_target("fn process_data() {}"));
assert!(!is_token_like_target("const FOO: i32 = 1;"));
assert!(!is_token_like_target("server.port"));
assert!(!is_token_like_target(""));
assert!(!is_token_like_target(" "));
}
#[test]
fn fuzzy_fails_min_floor_fail_closed_without_score() {
assert!(fuzzy_fails_min_floor(None, 0.8));
assert!(fuzzy_fails_min_floor(Some(0.5), 0.8));
assert!(!fuzzy_fails_min_floor(Some(0.9), 0.8));
assert!(!fuzzy_fails_min_floor(Some(0.8), 0.8));
}
#[test]
fn resolve_kebab_case_uses_line_similarity_not_broken_token_path() {
let content = "font-weight-primary: bold;\n";
let r = resolve_with_fallback(content, "font-weight-primry: bold;", None, None)
.expect("kebab line snippet should match via line similarity");
assert_eq!(r.strategy, MatchStrategy::Similarity);
assert!(
r.matched_text.contains("font-weight-primary"),
"{:?}",
r.matched_text
);
}
#[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);
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() {}"),
);
let r = result.expect("anchor fallback should succeed");
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() {}"),
);
let r = result.expect("multi-line anchor should succeed");
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]
);
}
#[test]
fn validate_edit_nth_replaces_only_nth_occurrence() {
let json = r#"{"a": "val", "b": "val"}"#;
let result = validate_edit_nth(json, "val", "new", Some("data.json"), Some(1));
assert!(result.valid, "nth=1 should produce valid JSON");
}
#[test]
fn validate_edit_nth_none_replaces_all() {
let content = "aXbXc";
let result = validate_edit_nth(content, "X", "Y", None, None);
assert!(result.valid);
}
#[test]
fn validate_edit_nth_out_of_range() {
let content = "aXbXc";
let result = validate_edit_nth(content, "X", "Y", None, Some(5));
assert!(
!result.valid,
"nth beyond occurrence count should be invalid"
);
assert!(
result.errors[0].contains("occurrence 5 not found"),
"error should mention the missing occurrence: {:?}",
result.errors
);
}
#[test]
fn validate_edit_nth_zero_rejected() {
let content = r#"{"a": "val"}"#;
let result = validate_edit_nth(content, "val", "X", Some("f.json"), Some(0));
assert!(!result.valid);
assert!(result.errors[0].contains("nth must be >= 1"));
}
#[test]
fn validate_edit_nth_detects_invalid_json_for_single_occurrence() {
let json = r#"{"a": "value", "b": "value"}"#;
let result = validate_edit_nth(json, "\"value\"", "broken}", Some("config.json"), Some(1));
assert!(!result.valid, "nth=1 should detect broken JSON");
}
#[test]
fn anchor_match_after_only_context() {
let content = "fn setup() {}\nfn proccess_data(x: i32) {}\nfn cleanup() {}\n";
let result = anchor_match(
content,
"fn process_data(x: i32) {}",
None,
Some("fn cleanup() {}"),
);
let r = result.expect("after-only context should find anchor match");
assert_eq!(r.strategy, MatchStrategy::Anchor);
assert!(r.matched_text.contains("proccess_data"));
}
#[test]
fn anchor_match_multiple_candidates_returns_first() {
let content = "fn header() {}\nfn proccess(x: i32) {}\nfn middle() {}\nfn proccess(y: bool) {}\nfn footer() {}\n";
let result = anchor_match(
content,
"fn process(x: i32) {}",
Some("fn header() {}"),
Some("fn middle() {}"),
);
let r = result.expect("should match first candidate");
assert_eq!(r.strategy, MatchStrategy::Anchor);
assert!(
r.matched_text.contains("x: i32"),
"should match first occurrence, got: {}",
r.matched_text
);
}
#[test]
fn resolve_with_fallback_empty_content() {
let result = resolve_with_fallback("", "fn hello()", None, None);
let err = result.unwrap_err();
assert_eq!(err.kind, EditErrorKind::NoMatch);
}
#[test]
fn anchor_match_multiline_before_context() {
let content = "fn setup() {}\nfn target() {}\nfn cleanup() {}\n";
let result = anchor_match(
content,
"fn target() {}",
Some("fn other() {}\nfn setup() {}"),
None,
);
assert!(
result.is_some(),
"anchor_match should match using the last line of multi-line before_context"
);
assert_eq!(result.unwrap().matched_text, "fn target() {}");
}
#[test]
fn anchor_match_multiline_after_context() {
let content = "fn setup() {}\nfn target() {}\nfn cleanup() {}\n";
let result = anchor_match(
content,
"fn target() {}",
None,
Some("fn cleanup() {}\nfn extra() {}"),
);
assert!(
result.is_some(),
"anchor_match should match using the first line of multi-line after_context"
);
assert_eq!(result.unwrap().matched_text, "fn target() {}");
}
const _: () = {
fn _assert<T: Send + Sync>() {}
let _ = _assert::<EditError>;
let _ = _assert::<EditErrorKind>;
let _ = _assert::<ValidationResult>;
let _ = _assert::<AnchorMatchResult>;
let _ = _assert::<MatchStrategy>;
};
}