Skip to main content

harness_write/
engine.rs

1use harness_core::{ToolError, ToolErrorCode};
2
3use crate::format::{format_fuzzy_candidates, format_match_locations};
4use crate::matching::{
5    build_match_locations, find_all_occurrences, find_fuzzy_candidates,
6    substring_boundary_collisions, FuzzyOpts,
7};
8use crate::normalize::normalize_line_endings;
9use crate::schema::EditSpec;
10
11pub struct ApplyResult {
12    pub content: String,
13    pub replacements: usize,
14    pub warnings: Vec<String>,
15}
16
17pub enum PipelineResult {
18    Ok {
19        content: String,
20        total_replacements: usize,
21        warnings: Vec<String>,
22    },
23    Err {
24        error: ToolError,
25        index: usize,
26    },
27}
28
29/// Apply a single edit spec to `content`. Returns either the new content +
30/// counts, or a structured ToolError.
31pub fn apply_edit(content: &str, edit: &EditSpec) -> Result<ApplyResult, ToolError> {
32    let old_raw = &edit.old_string;
33    let new_raw = &edit.new_string;
34
35    if old_raw == new_raw {
36        return Err(ToolError::new(
37            ToolErrorCode::NoOpEdit,
38            "old_string equals new_string (no-op edit). If you intended to verify file state, use Read instead.",
39        ));
40    }
41
42    let norm_content = normalize_line_endings(content);
43    let norm_old = normalize_line_endings(old_raw);
44    let norm_new = normalize_line_endings(new_raw);
45
46    if norm_content.is_empty() {
47        return Err(ToolError::new(
48            ToolErrorCode::EmptyFile,
49            "Edit cannot anchor to an empty file. Use Write to create initial content; Edit requires existing text as an anchor.",
50        ));
51    }
52
53    let offsets = find_all_occurrences(&norm_content, &norm_old);
54
55    if offsets.is_empty() {
56        let candidates = find_fuzzy_candidates(&norm_content, &norm_old, FuzzyOpts::default());
57        let block = format_fuzzy_candidates(&candidates);
58        let msg = if !block.is_empty() {
59            format!(
60                "old_string was not found in the file.\n\nClosest candidates:\n\n{}\n\nIf one of these is the intended location, re-emit Edit with old_string taken verbatim from the candidate block above. Otherwise, re-Read the file to confirm the expected text is present.",
61                block
62            )
63        } else {
64            "old_string was not found in the file, and no fuzzy candidates crossed the similarity threshold. Re-Read the file to confirm the expected text is present.".to_string()
65        };
66        return Err(ToolError::new(ToolErrorCode::OldStringNotFound, msg).with_meta(
67            serde_json::json!({
68                "candidates": candidates,
69            }),
70        ));
71    }
72
73    let replace_all = edit.replace_all.unwrap_or(false);
74    if offsets.len() > 1 && !replace_all {
75        let locations = build_match_locations(&norm_content, &norm_old, &offsets);
76        let block = format_match_locations(&locations);
77        let msg = format!(
78            "old_string matches {} locations; edit requires exactly one match.\n\n{}\n\nWiden old_string with surrounding context so it matches exactly one location, or pass replace_all: true if you intend to replace every occurrence.",
79            offsets.len(),
80            block
81        );
82        return Err(ToolError::new(ToolErrorCode::OldStringNotUnique, msg).with_meta(
83            serde_json::json!({
84                "match_count": offsets.len(),
85                "locations": locations,
86            }),
87        ));
88    }
89
90    let target_offsets: Vec<usize> = if replace_all {
91        offsets.clone()
92    } else {
93        vec![offsets[0]]
94    };
95
96    let mut warnings: Vec<String> = Vec::new();
97    if replace_all && offsets.len() > 1 {
98        let flagged = substring_boundary_collisions(&norm_content, &norm_old, &offsets);
99        if !flagged.is_empty() {
100            let lines_str = flagged
101                .iter()
102                .map(|n| n.to_string())
103                .collect::<Vec<_>>()
104                .join(", ");
105            let needle_preview = truncate_for_warning(&norm_old);
106            warnings.push(format!(
107                "replace_all pattern \"{}\" is adjacent to identifier characters at line(s) {}; verify these replacements did not land inside a larger identifier.",
108                needle_preview, lines_str
109            ));
110        }
111    }
112
113    let new_content = replace_at_offsets(&norm_content, &norm_old, &norm_new, &target_offsets);
114
115    Ok(ApplyResult {
116        content: new_content,
117        replacements: target_offsets.len(),
118        warnings,
119    })
120}
121
122pub fn apply_pipeline(initial: &str, edits: &[EditSpec]) -> PipelineResult {
123    let mut content = initial.to_string();
124    let mut total = 0usize;
125    let mut warnings: Vec<String> = Vec::new();
126
127    for (i, edit) in edits.iter().enumerate() {
128        match apply_edit(&content, edit) {
129            Ok(r) => {
130                content = r.content;
131                total += r.replacements;
132                for w in r.warnings {
133                    warnings.push(format!("edit[{}]: {}", i, w));
134                }
135            }
136            Err(e) => {
137                let msg = format!("edit[{}]: {}", i, e.message);
138                let mut meta = e.meta.clone().unwrap_or_else(|| serde_json::json!({}));
139                meta["edit_index"] = serde_json::json!(i);
140                let new_err = ToolError::new(e.code, msg).with_meta(meta);
141                return PipelineResult::Err {
142                    error: new_err,
143                    index: i,
144                };
145            }
146        }
147    }
148
149    PipelineResult::Ok {
150        content,
151        total_replacements: total,
152        warnings,
153    }
154}
155
156fn replace_at_offsets(
157    haystack: &str,
158    needle: &str,
159    replacement: &str,
160    offsets: &[usize],
161) -> String {
162    if offsets.is_empty() {
163        return haystack.to_string();
164    }
165    let needle_len = needle.as_bytes().len();
166    let mut out = String::with_capacity(haystack.len());
167    let mut cursor = 0usize;
168    for &off in offsets {
169        out.push_str(&haystack[cursor..off]);
170        out.push_str(replacement);
171        cursor = off + needle_len;
172    }
173    out.push_str(&haystack[cursor..]);
174    out
175}
176
177fn truncate_for_warning(s: &str) -> String {
178    let one_line: String = s.chars().map(|c| if c == '\n' { ' ' } else { c }).collect();
179    if one_line.chars().count() <= 40 {
180        one_line
181    } else {
182        let mut out: String = one_line.chars().take(37).collect();
183        out.push_str("...");
184        out
185    }
186}