1#![forbid(unsafe_code)]
2use std::path::{Path, PathBuf};
12
13pub fn apply_edit(root: &str, file: &str, old: &str, new: &str) -> Result<bool, String> {
16 if old.trim().is_empty() {
20 return Err("apply_edit: `old` text is empty — refusing to edit".to_string());
21 }
22 let full: PathBuf = if Path::new(file).is_absolute() {
23 PathBuf::from(file)
24 } else {
25 Path::new(root).join(file)
26 };
27 let path_str = full.to_string_lossy().to_string();
28 let content = std::fs::read_to_string(&full)
29 .map_err(|e| format!("read {}: {}", path_str, e))?;
30
31 if let Some(idx) = content.find(old) {
33 let mut out = content.clone();
34 out.replace_range(idx..idx + old.len(), new);
35 write_file(&full, &out)?;
36 return Ok(true);
37 }
38
39 let old_norm = collapse_ws(old);
43 if let Some((start, end)) = fuzzy_span(&content, &old_norm) {
44 let mut out = content.clone();
45 out.replace_range(start..end, new);
46 write_file(&full, &out)?;
47 return Ok(true);
48 }
49
50 Ok(false)
51}
52
53fn fuzzy_span(hay: &str, needle: &str) -> Option<(usize, usize)> {
57 if needle.is_empty() {
58 return Some((0, 0));
59 }
60 let needle_toks: Vec<char> = needle.chars().filter(|c| !c.is_whitespace()).collect();
62 if needle_toks.is_empty() {
63 return Some((0, 0));
64 }
65 let need_len = needle_toks.len();
67 let hay_chars: Vec<(usize, char)> = hay
68 .char_indices()
69 .filter(|(_, c)| !c.is_whitespace())
70 .collect();
71 if hay_chars.len() < need_len {
72 return None;
73 }
74 'win: for w in 0..=hay_chars.len() - need_len {
75 for k in 0..need_len {
76 if hay_chars[w + k].1 != needle_toks[k] {
77 continue 'win;
78 }
79 }
80 let start = hay_chars[w].0;
82 let last = hay_chars[w + need_len - 1];
83 let end = last.0 + last.1.len_utf8();
84 return Some((start, end));
85 }
86 None
87}
88
89fn collapse_ws(s: &str) -> String {
91 let mut out = String::with_capacity(s.len());
92 let mut prev_ws = false;
93 for c in s.chars() {
94 if c.is_whitespace() {
95 if !prev_ws {
96 out.push(' ');
97 }
98 prev_ws = true;
99 } else {
100 out.push(c);
101 prev_ws = false;
102 }
103 }
104 out
105}
106
107fn write_file(path: &Path, content: &str) -> Result<(), String> {
109 let tmp = path.with_extension("reliary-tmp");
110 std::fs::write(&tmp, content).map_err(|e| format!("write {}: {}", tmp.display(), e))?;
111 std::fs::rename(&tmp, path).map_err(|e| format!("rename {}: {}", path.display(), e))?;
112 Ok(())
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118 use std::io::Write;
119
120 use std::sync::atomic::{AtomicUsize, Ordering};
121 static CTR: AtomicUsize = AtomicUsize::new(0);
122
123 fn tmp_(content: &str) -> (PathBuf, std::fs::File) {
124 let n = CTR.fetch_add(1, Ordering::SeqCst);
125 let mut p = std::env::temp_dir();
126 p.push(format!("reliary_edit_test_{}_{}.rs", std::process::id(), n));
127 let mut f = std::fs::File::create(&p).unwrap();
128 f.write_all(content.as_bytes()).unwrap();
129 (p, f)
130 }
131
132 #[test]
133 fn exact_replace() {
134 let (p, _) = tmp_("fn foo() { bar(); }");
135 let root = p.parent().unwrap().to_str().unwrap();
136 let file = p.file_name().unwrap().to_str().unwrap();
137 let ok = apply_edit(root, file, "bar()", "baz()").unwrap();
138 assert!(ok);
139 assert_eq!(std::fs::read_to_string(&p).unwrap(), "fn foo() { baz(); }");
140 }
141
142 #[test]
143 fn whitespace_insensitive() {
144 let (p, _) = tmp_("fn foo( a : i32, b : i32 ) { x; }");
145 let root = p.to_str().unwrap();
146 let ok = apply_edit(root, p.to_str().unwrap(), "fn foo(a: i32, b: i32)", "fn foo(a: i64, b: i32)").unwrap();
147 assert!(ok);
148 let out = std::fs::read_to_string(&p).unwrap();
149 assert!(out.contains("fn foo(a: i64, b: i32)"));
150 }
151
152 #[test]
153 fn no_match() {
154 let (p, _) = tmp_("fn foo() { x; }");
155 let root = p.to_str().unwrap();
156 let ok = apply_edit(root, p.to_str().unwrap(), "fn foo()", "fn bar()").unwrap();
158 assert!(ok);
159 }
160}
161
162#[cfg(test)]
163mod v80_tests {
164 use super::*;
165 #[test]
166 fn empty_old_is_refused() {
167 let d = std::env::temp_dir().join(format!("reliary_edit_{}", std::process::id()));
168 std::fs::create_dir_all(&d).unwrap();
169 let f = d.join("a.rs");
170 std::fs::write(&f, "fn foo() {}\n").unwrap();
171 let r = apply_edit(d.to_str().unwrap(), "a.rs", "", "XXX");
172 assert!(r.is_err(), "empty old must be refused, got {:?}", r);
173 assert_eq!(std::fs::read_to_string(&f).unwrap(), "fn foo() {}\n", "file must be unchanged");
174 let r2 = apply_edit(d.to_str().unwrap(), "a.rs", " \n ", "XXX");
175 assert!(r2.is_err(), "whitespace-only old must be refused");
176 let _ = std::fs::remove_dir_all(&d);
177 }
178 #[test]
179 fn normal_edit_still_works() {
180 let d = std::env::temp_dir().join(format!("reliary_edit2_{}", std::process::id()));
181 std::fs::create_dir_all(&d).unwrap();
182 let f = d.join("a.rs");
183 std::fs::write(&f, "fn foo() { 1 }\n").unwrap();
184 let r = apply_edit(d.to_str().unwrap(), "a.rs", "1", "2").unwrap();
185 assert!(r);
186 assert_eq!(std::fs::read_to_string(&f).unwrap(), "fn foo() { 2 }\n");
187 let _ = std::fs::remove_dir_all(&d);
188 }
189}