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
21const DRY_RUN_WARNING: &str = "Dry run: nothing was written — reissue without \
24 `dry_run` to apply this exact edit.";
25
26const ERROR_CONTEXT_LINES: u32 = 2;
28
29const 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
38fn 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 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 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 #[serde(skip_serializing_if = "Option::is_none")]
129 pub lsp_notes: Option<super::types::LspNotes>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub dry_run: Option<bool>,
134}
135
136#[derive(Debug)]
147pub struct EditBatchError {
148 pub failed_path: String,
150 pub cause: String,
152 pub applied: Vec<EditResult>,
154 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 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
195pub 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 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 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 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(§ion.before, §ion.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}