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