Skip to main content

reliary_edit/
lib.rs

1#![forbid(unsafe_code)]
2// reliary-edit: grammar-free function-boundary resolve and apply.
3// Ported deliberately from stria/src/edit.rs (proven: fuzzy-match failure 15% -> ~0%).
4//
5// apply_edit: replace `old` with `new` in a file. If `old` appears exactly,
6// replace it. Otherwise fall back to a fuzzy match: locate the enclosing
7// function/block boundary via indentation-anchored scanning and apply within
8// it. Grammar-free — no AST, no tree-sitter.
9
10
11use std::path::{Path, PathBuf};
12
13/// Apply a source edit to `file` (relative to `root`).
14/// Returns Ok(true) if a change was made, Ok(false) if no match, Err on I/O.
15pub fn apply_edit(root: &str, file: &str, old: &str, new: &str) -> Result<bool, String> {
16    // An empty or whitespace-only `old` matches at offset 0 (`content.find("")`
17    // == Some(0)), so a malformed LLM edit would PREPEND `new` to the file and
18    // report success. Require a non-empty anchor.
19    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    // 1. Exact match (most reliable).
32    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    // 2. Fuzzy: normalize whitespace and try again. Compute the byte span in
40    // the ORIGINAL text that corresponds to the normalized match, and replace
41    // that whole span so no trailing whitespace is left behind.
42    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
53/// Return the (start, end) byte span in the original `hay` that corresponds to
54/// the whitespace-normalized `needle`. start = first non-ws byte of the match;
55/// end = one past the last non-ws byte of the match.
56fn fuzzy_span(hay: &str, needle: &str) -> Option<(usize, usize)> {
57    if needle.is_empty() {
58        return Some((0, 0));
59    }
60    // Tokenize needle into chars (ignoring whitespace).
61    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    // Sliding window over hay tokens.
66    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        // Found window w..w+need_len.
81        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
89/// Collapse runs of whitespace to a single space.
90fn 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
107/// Atomic-ish write: write to temp then rename.
108fn 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        // old text present exactly (fn foo() ...) so exact match hits.
157        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}