Skip to main content

cosh_tools/fs/
edit.rs

1use cosh_sdk::hashline::normalize::{normalize_to_lf, strip_bom};
2use cosh_sdk::hashline::{
3    diff::structured_patch,
4    format::{HL_FILE_PREFIX, compute_file_hash, format_hashline_header, format_numbered_line},
5    fs::DiskFilesystem,
6    input::Patch,
7    patcher::{Patcher, merge_warnings},
8    types::{BlockResolver, BlockResolverRequest, BlockSpan, SplitOptions},
9};
10use cosh_sdk::rollback;
11
12use super::types::{EditTarget, FsEdit, FsMetadata};
13use crate::util::path_guard::assert_editable_file;
14use regex::Regex;
15use serde::Serialize;
16use std::collections::BTreeSet;
17use std::fmt;
18use std::path::Path;
19use std::sync::LazyLock;
20
21/// Marker appended to a dry-run result's warnings so the model cannot
22/// mistake a preview for an applied edit.
23const DRY_RUN_WARNING: &str = "Dry run: nothing was written — reissue without \
24     `dry_run` to apply this exact edit.";
25
26/// Lines of context shown either side of an error-referenced line.
27const ERROR_CONTEXT_LINES: u32 = 2;
28
29/// Soft cap on distinct anchors rendered in one enriched error: beyond this
30/// the model re-anchors from the header anyway, and an unbounded block lets a
31/// pathological error string (line refs echoed from file content) flood it.
32const MAX_ERROR_ANCHORS: usize = 8;
33
34#[allow(clippy::unwrap_used)]
35static ERROR_LINE_REF_RE: LazyLock<Regex> =
36    LazyLock::new(|| Regex::new(r"(?i)line (\d+)").unwrap());
37
38/// Enrich a failed edit's error with the file's CURRENT anchor so the model
39/// can re-issue the corrected edit without a re-read round trip.
40///
41/// A successful edit returns a fresh `¶path#TAG`, but the error path used to
42/// return bare prose — the model then had no valid tag to retry with and
43/// burned rounds re-reading the file just to mint one. Every failure carries
44/// the live `¶path#TAG` plus (when the error references line numbers) the
45/// current content of those lines, so the next attempt is informed.
46///
47/// `MismatchError` diagnostics are left untouched: they already render the
48/// current anchor and context themselves (their message embeds `¶` headers).
49fn enrich_edit_error(display_path: &str, read_path: &str, err: &str) -> String {
50    if err.contains(HL_FILE_PREFIX) {
51        return err.to_string();
52    }
53    // Nothing was written on any error path (prepare is in-memory; commit is
54    // a single write), so the disk state here is exactly the pre-edit content
55    // the next attempt will be validated against.
56    let Ok(bytes) = std::fs::read(read_path) else {
57        return err.to_string();
58    };
59    let text = normalize_to_lf(&strip_bom(&String::from_utf8_lossy(&bytes)).text);
60    let hash = compute_file_hash(&text);
61    let lines: Vec<&str> = text.split('\n').collect();
62
63    let mut out = format!(
64        "{err}\n\nCurrent anchor: {HL_FILE_PREFIX}{display_path}#{hash} — the file has {} \
65         lines. Re-issue the corrected edit with THIS tag; no re-read is needed.",
66        lines.len(),
67    );
68
69    // When the error names line numbers (out-of-range anchors, parse failures
70    // at `line N:`, boundary rejections), show the live content around them —
71    // the model authored the ops against stale or elided output.
72    let anchors: BTreeSet<u32> = ERROR_LINE_REF_RE
73        .captures_iter(err)
74        .filter_map(|c| c[1].parse::<u32>().ok())
75        .filter(|n| (1..=lines.len() as u32).contains(n))
76        .collect();
77    if anchors.is_empty() {
78        return out;
79    }
80    let truncated = anchors.len() > MAX_ERROR_ANCHORS;
81    let shown: BTreeSet<u32> = anchors.iter().take(MAX_ERROR_ANCHORS).copied().collect();
82    let mut display: BTreeSet<u32> = BTreeSet::new();
83    for &line in &shown {
84        let lo = 1u32.max(line.saturating_sub(ERROR_CONTEXT_LINES));
85        let hi = (lines.len() as u32).min(line + ERROR_CONTEXT_LINES);
86        display.extend(lo..=hi);
87    }
88    out.push_str("\nLive content at the referenced line(s):\n");
89    let mut previous: Option<u32> = None;
90    for line_num in display {
91        if previous.is_some_and(|p| line_num > p + 1) {
92            out.push_str("...\n");
93        }
94        previous = Some(line_num);
95        let marker = if anchors.contains(&line_num) {
96            "*"
97        } else {
98            " "
99        };
100        out.push_str(marker);
101        out.push_str(&format_numbered_line(
102            line_num,
103            lines[(line_num - 1) as usize],
104        ));
105        out.push('\n');
106    }
107    if truncated {
108        out.push_str(&format!(
109            "... ({} more referenced line(s) omitted — re-anchor from the tag above)\n",
110            anchors.len() - MAX_ERROR_ANCHORS
111        ));
112    }
113    out
114}
115
116#[derive(Debug, Serialize)]
117pub struct EditResult {
118    pub path: String,
119    pub file_hash: String,
120    pub header: String,
121    pub first_changed_line: Option<u32>,
122    pub warnings: Vec<String>,
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub diff: Option<String>,
125    /// Passive LSP feedback collected after the edit (errors by default,
126    /// warnings when the caller opted in). `None` when LSP is disabled or
127    /// nothing was found.
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub lsp_notes: Option<super::types::LspNotes>,
130    /// `Some(true)` when the result came from a preview (`dry_run`) — the
131    /// edit was validated in memory but NOT written.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub dry_run: Option<bool>,
134}
135
136/// Failure of a multi-target [`edit`] batch.
137///
138/// Targets are applied in order, and order carries intention: when target N
139/// fails, the batch stops there. Targets applied before N are already
140/// committed and stay (see [`applied`](Self::applied)); targets after N are
141/// deliberately NOT attempted ([`skipped`](Self::skipped)) because their
142/// edits may depend on N succeeding first.
143///
144/// Only the failing target is reported as the cause — the targets that follow
145/// are reported as skipped *as a consequence*, never as independent failures.
146#[derive(Debug)]
147pub struct EditBatchError {
148    /// Path of the target that failed — the root cause of the batch abort.
149    pub failed_path: String,
150    /// The underlying error reported for the failing target.
151    pub cause: String,
152    /// Results of targets applied before the failure — already committed to disk.
153    pub applied: Vec<EditResult>,
154    /// Paths of targets after the failure that were never attempted.
155    pub skipped: Vec<String>,
156}
157
158impl fmt::Display for EditBatchError {
159    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160        write!(f, "edit failed for `{}`: {}", self.failed_path, self.cause)?;
161        if !self.applied.is_empty() {
162            // Each applied result carries its new hashline anchor (`¶path#TAG`),
163            // so the model can keep editing those files without re-reading.
164            write!(
165                f,
166                "\nApplied before the failure (fresh tags for follow-up edits):"
167            )?;
168            for r in &self.applied {
169                let anchor = if r.header.is_empty() {
170                    r.path.clone()
171                } else {
172                    r.header.clone()
173                };
174                write!(f, "\n  {anchor}")?;
175            }
176        }
177        if !self.skipped.is_empty() {
178            let paths: Vec<&str> = self.skipped.iter().map(|s| s.as_str()).collect();
179            write!(
180                f,
181                "\nNot applied (skipped because `{}` failed): {}",
182                self.failed_path,
183                paths.join(", ")
184            )?;
185        }
186        Ok(())
187    }
188}
189
190#[allow(clippy::needless_pass_by_value)]
191fn resolve_block_fn(req: BlockResolverRequest) -> Option<BlockSpan> {
192    cosh_sdk::tree_sitter::tree_sitter().resolve_block(&req.path, &req.text, req.line)
193}
194
195/// Apply edits to one or more files, in order.
196///
197/// Targets are applied sequentially. If target N fails, the batch stops
198/// there: targets applied before N are kept (see
199/// [`EditBatchError::applied`]), and targets after N are deliberately not
200/// attempted ([`EditBatchError::skipped`]) because their edits may depend on
201/// N. Only the failing target is reported as the cause of the abort.
202///
203/// # Errors
204///
205/// Returns [`EditBatchError`] when a file hash doesn't match, edit operations
206/// fail to parse, or the underlying filesystem returns an error.
207pub async fn edit(metadata: FsMetadata, tg: FsEdit) -> Result<Vec<EditResult>, EditBatchError> {
208    let mut results = Vec::new();
209
210    for (i, target) in tg.targets.iter().enumerate() {
211        match edit_target(target.clone(), &metadata, None, tg.dry_run).await {
212            Ok(result) => results.push(result),
213            Err(cause) => {
214                let skipped = tg.targets[i + 1..].iter().map(|t| t.path.clone()).collect();
215                return Err(EditBatchError {
216                    failed_path: target.path.clone(),
217                    cause,
218                    applied: results,
219                    skipped,
220                });
221            }
222        }
223    }
224
225    Ok(results)
226}
227
228pub(crate) async fn edit_target(
229    target: EditTarget,
230    metadata: &FsMetadata,
231    expected_after: Option<&str>,
232    dry_run: bool,
233) -> Result<EditResult, String> {
234    let validated_path = metadata.fs_guard(&target.path)?;
235
236    let path_str = validated_path.to_string_lossy().to_string();
237
238    // Never modify a file that declares itself machine-generated (same guard
239    // as `write`); the change would be lost on the next generation run.
240    assert_editable_file(Path::new(&path_str))?;
241
242    let hashline_input = format!(
243        "{prefix}{path}#{hash}\n{ops}",
244        prefix = HL_FILE_PREFIX,
245        path = target.path,
246        hash = target.file_hash,
247        ops = target.ops,
248    );
249
250    let patch = Patch::parse(&hashline_input, &SplitOptions::default()).map_err(|e| {
251        enrich_edit_error(
252            &target.path,
253            &path_str,
254            &format!(
255                "failed to parse edit operations for `{}`: {}",
256                target.path, e
257            ),
258        )
259    })?;
260
261    let session_store = rollback::session_store().clone();
262    let mut patcher = Patcher::new_shared(
263        DiskFilesystem::new(),
264        session_store,
265        Some(resolve_block_fn as BlockResolver),
266    );
267
268    let prepared = patcher
269        .prepare(&patch.sections[0])
270        .await
271        .map_err(|e| enrich_edit_error(&target.path, &path_str, &e.to_string()))?;
272
273    // Content-anchored callers pass the exact post-edit text they expect.
274    // The hashline engine's boundary-echo and indent-repair heuristics may
275    // legitimately rewrite a hand-authored `ops` payload, but for an exact
276    // replacement they would silently corrupt the contract — reject instead,
277    // before anything is written (prepare is in-memory only).
278    if let Some(expected) = expected_after
279        && prepared.apply_result.text != expected
280    {
281        return Err(enrich_edit_error(
282            &target.path,
283            &path_str,
284            &format!(
285                "content replace for `{}` deviated from the exact replacement: the \
286                 hashline engine's boundary/indent repair altered the payload. \
287                 Re-read the touched region and reissue with the `targets` engine \
288                 if the repaired form is acceptable.",
289                target.path
290            ),
291        ));
292    }
293
294    // Dry run: stop after the in-memory apply. The model gets the diff and
295    // the syntax-probe verdict (the applier pushes `edit_broke_parse_warning`
296    // into the warnings when the result no longer parses) without anything
297    // touching the disk — no commit, no rollback record, no LSP pull. The
298    // warnings merge mirrors `commit` (parse warnings + apply warnings) so
299    // the preview reports exactly what the real edit would.
300    if dry_run {
301        let after = &prepared.apply_result.text;
302        let mut warnings = merge_warnings(&[
303            Some(&prepared.parse_warnings),
304            Some(&prepared.apply_result.warnings),
305        ]);
306        warnings.push(DRY_RUN_WARNING.to_string());
307        let hash = compute_file_hash(after);
308        return Ok(EditResult {
309            path: target.path.clone(),
310            file_hash: hash.clone(),
311            header: format_hashline_header(&target.path, &hash),
312            first_changed_line: prepared.apply_result.first_changed_line,
313            warnings,
314            lsp_notes: None,
315            dry_run: Some(true),
316            diff: if *after == prepared.normalized {
317                None
318            } else {
319                Some(
320                    structured_patch(&prepared.normalized, after, 3)
321                        .to_unified_diff(&target.path, &target.path),
322                )
323            },
324        });
325    }
326
327    let _ = rollback::record(&path_str, &prepared.normalized);
328
329    let section = patcher
330        .commit(prepared)
331        .await
332        .map_err(|e| enrich_edit_error(&target.path, &path_str, &e.to_string()))?;
333
334    cosh_sdk::tree_sitter::tree_sitter().invalidate(&path_str);
335
336    let diff = if section.before == section.after {
337        None
338    } else {
339        Some(
340            structured_patch(&section.before, &section.after, 3)
341                .to_unified_diff(&target.path, &target.path),
342        )
343    };
344
345    Ok(EditResult {
346        path: target.path.clone(),
347        file_hash: section.file_hash,
348        header: section.header,
349        first_changed_line: section.first_changed_line,
350        warnings: section.warnings,
351        lsp_notes: None,
352        dry_run: None,
353        diff,
354    })
355}