1use std::path::{Path, PathBuf};
10
11pub fn apply_edit(root: &str, file: &str, old: &str, new: &str) -> Result<bool, String> {
14 let full: PathBuf = if Path::new(file).is_absolute() {
15 PathBuf::from(file)
16 } else {
17 Path::new(root).join(file)
18 };
19 let path_str = full.to_string_lossy().to_string();
20 let content = std::fs::read_to_string(&full)
21 .map_err(|e| format!("read {}: {}", path_str, e))?;
22
23 if let Some(idx) = content.find(old) {
25 let mut out = content.clone();
26 out.replace_range(idx..idx + old.len(), new);
27 write_file(&full, &out)?;
28 return Ok(true);
29 }
30
31 let old_norm = collapse_ws(old);
35 if let Some((start, end)) = fuzzy_span(&content, &old_norm) {
36 let mut out = content.clone();
37 out.replace_range(start..end, new);
38 write_file(&full, &out)?;
39 return Ok(true);
40 }
41
42 Ok(false)
43}
44
45fn fuzzy_span(hay: &str, needle: &str) -> Option<(usize, usize)> {
49 if needle.is_empty() {
50 return Some((0, 0));
51 }
52 let needle_toks: Vec<char> = needle.chars().filter(|c| !c.is_whitespace()).collect();
54 if needle_toks.is_empty() {
55 return Some((0, 0));
56 }
57 let need_len = needle_toks.len();
59 let hay_chars: Vec<(usize, char)> = hay
60 .char_indices()
61 .filter(|(_, c)| !c.is_whitespace())
62 .collect();
63 if hay_chars.len() < need_len {
64 return None;
65 }
66 'win: for w in 0..=hay_chars.len() - need_len {
67 for k in 0..need_len {
68 if hay_chars[w + k].1 != needle_toks[k] {
69 continue 'win;
70 }
71 }
72 let start = hay_chars[w].0;
74 let last = hay_chars[w + need_len - 1];
75 let end = last.0 + last.1.len_utf8();
76 return Some((start, end));
77 }
78 None
79}
80
81fn collapse_ws(s: &str) -> String {
83 let mut out = String::with_capacity(s.len());
84 let mut prev_ws = false;
85 for c in s.chars() {
86 if c.is_whitespace() {
87 if !prev_ws {
88 out.push(' ');
89 }
90 prev_ws = true;
91 } else {
92 out.push(c);
93 prev_ws = false;
94 }
95 }
96 out
97}
98
99fn write_file(path: &Path, content: &str) -> Result<(), String> {
101 let tmp = path.with_extension("reliary-tmp");
102 std::fs::write(&tmp, content).map_err(|e| format!("write {}: {}", tmp.display(), e))?;
103 std::fs::rename(&tmp, path).map_err(|e| format!("rename {}: {}", path.display(), e))?;
104 Ok(())
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110 use std::io::Write;
111
112 use std::sync::atomic::{AtomicUsize, Ordering};
113 static CTR: AtomicUsize = AtomicUsize::new(0);
114
115 fn tmp_(content: &str) -> (PathBuf, std::fs::File) {
116 let n = CTR.fetch_add(1, Ordering::SeqCst);
117 let mut p = std::env::temp_dir();
118 p.push(format!("reliary_edit_test_{}_{}.rs", std::process::id(), n));
119 let mut f = std::fs::File::create(&p).unwrap();
120 f.write_all(content.as_bytes()).unwrap();
121 (p, f)
122 }
123
124 #[test]
125 fn exact_replace() {
126 let (p, _) = tmp_("fn foo() { bar(); }");
127 let root = p.parent().unwrap().to_str().unwrap();
128 let file = p.file_name().unwrap().to_str().unwrap();
129 let ok = apply_edit(root, file, "bar()", "baz()").unwrap();
130 assert!(ok);
131 assert_eq!(std::fs::read_to_string(&p).unwrap(), "fn foo() { baz(); }");
132 }
133
134 #[test]
135 fn whitespace_insensitive() {
136 let (p, _) = tmp_("fn foo( a : i32, b : i32 ) { x; }");
137 let root = p.to_str().unwrap();
138 let ok = apply_edit(root, p.to_str().unwrap(), "fn foo(a: i32, b: i32)", "fn foo(a: i64, b: i32)").unwrap();
139 assert!(ok);
140 let out = std::fs::read_to_string(&p).unwrap();
141 assert!(out.contains("fn foo(a: i64, b: i32)"));
142 }
143
144 #[test]
145 fn no_match() {
146 let (p, _) = tmp_("fn foo() { x; }");
147 let root = p.to_str().unwrap();
148 let ok = apply_edit(root, p.to_str().unwrap(), "fn foo()", "fn bar()").unwrap();
150 assert!(ok);
151 }
152}