use cosh_sdk::hashline::normalize::{normalize_to_lf, strip_bom};
use cosh_sdk::hashline::{
diff::structured_patch,
format::{HL_FILE_PREFIX, compute_file_hash, format_hashline_header, format_numbered_line},
fs::DiskFilesystem,
input::Patch,
patcher::{Patcher, merge_warnings},
types::{BlockResolver, BlockResolverRequest, BlockSpan, SplitOptions},
};
use cosh_sdk::rollback;
use super::types::{EditTarget, FsEdit, FsMetadata};
use crate::util::path_guard::assert_editable_file;
use regex::Regex;
use serde::Serialize;
use std::collections::BTreeSet;
use std::fmt;
use std::path::Path;
use std::sync::LazyLock;
const DRY_RUN_WARNING: &str = "Dry run: nothing was written — reissue without \
`dry_run` to apply this exact edit.";
const ERROR_CONTEXT_LINES: u32 = 2;
const MAX_ERROR_ANCHORS: usize = 8;
#[allow(clippy::unwrap_used)]
static ERROR_LINE_REF_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)line (\d+)").unwrap());
fn enrich_edit_error(display_path: &str, read_path: &str, err: &str) -> String {
if err.contains(HL_FILE_PREFIX) {
return err.to_string();
}
let Ok(bytes) = std::fs::read(read_path) else {
return err.to_string();
};
let text = normalize_to_lf(&strip_bom(&String::from_utf8_lossy(&bytes)).text);
let hash = compute_file_hash(&text);
let lines: Vec<&str> = text.split('\n').collect();
let mut out = format!(
"{err}\n\nCurrent anchor: {HL_FILE_PREFIX}{display_path}#{hash} — the file has {} \
lines. Re-issue the corrected edit with THIS tag; no re-read is needed.",
lines.len(),
);
let anchors: BTreeSet<u32> = ERROR_LINE_REF_RE
.captures_iter(err)
.filter_map(|c| c[1].parse::<u32>().ok())
.filter(|n| (1..=lines.len() as u32).contains(n))
.collect();
if anchors.is_empty() {
return out;
}
let truncated = anchors.len() > MAX_ERROR_ANCHORS;
let shown: BTreeSet<u32> = anchors.iter().take(MAX_ERROR_ANCHORS).copied().collect();
let mut display: BTreeSet<u32> = BTreeSet::new();
for &line in &shown {
let lo = 1u32.max(line.saturating_sub(ERROR_CONTEXT_LINES));
let hi = (lines.len() as u32).min(line + ERROR_CONTEXT_LINES);
display.extend(lo..=hi);
}
out.push_str("\nLive content at the referenced line(s):\n");
let mut previous: Option<u32> = None;
for line_num in display {
if previous.is_some_and(|p| line_num > p + 1) {
out.push_str("...\n");
}
previous = Some(line_num);
let marker = if anchors.contains(&line_num) {
"*"
} else {
" "
};
out.push_str(marker);
out.push_str(&format_numbered_line(
line_num,
lines[(line_num - 1) as usize],
));
out.push('\n');
}
if truncated {
out.push_str(&format!(
"... ({} more referenced line(s) omitted — re-anchor from the tag above)\n",
anchors.len() - MAX_ERROR_ANCHORS
));
}
out
}
#[derive(Debug, Serialize)]
pub struct EditResult {
pub path: String,
pub file_hash: String,
pub header: String,
pub first_changed_line: Option<u32>,
pub warnings: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub diff: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lsp_notes: Option<super::types::LspNotes>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dry_run: Option<bool>,
}
#[derive(Debug)]
pub struct EditBatchError {
pub failed_path: String,
pub cause: String,
pub applied: Vec<EditResult>,
pub skipped: Vec<String>,
}
impl fmt::Display for EditBatchError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "edit failed for `{}`: {}", self.failed_path, self.cause)?;
if !self.applied.is_empty() {
write!(
f,
"\nApplied before the failure (fresh tags for follow-up edits):"
)?;
for r in &self.applied {
let anchor = if r.header.is_empty() {
r.path.clone()
} else {
r.header.clone()
};
write!(f, "\n {anchor}")?;
}
}
if !self.skipped.is_empty() {
let paths: Vec<&str> = self.skipped.iter().map(|s| s.as_str()).collect();
write!(
f,
"\nNot applied (skipped because `{}` failed): {}",
self.failed_path,
paths.join(", ")
)?;
}
Ok(())
}
}
#[allow(clippy::needless_pass_by_value)]
fn resolve_block_fn(req: BlockResolverRequest) -> Option<BlockSpan> {
cosh_sdk::tree_sitter::tree_sitter().resolve_block(&req.path, &req.text, req.line)
}
pub async fn edit(metadata: FsMetadata, tg: FsEdit) -> Result<Vec<EditResult>, EditBatchError> {
let mut results = Vec::new();
for (i, target) in tg.targets.iter().enumerate() {
match edit_target(target.clone(), &metadata, None, tg.dry_run).await {
Ok(result) => results.push(result),
Err(cause) => {
let skipped = tg.targets[i + 1..].iter().map(|t| t.path.clone()).collect();
return Err(EditBatchError {
failed_path: target.path.clone(),
cause,
applied: results,
skipped,
});
}
}
}
Ok(results)
}
pub(crate) async fn edit_target(
target: EditTarget,
metadata: &FsMetadata,
expected_after: Option<&str>,
dry_run: bool,
) -> Result<EditResult, String> {
let validated_path = metadata.fs_guard(&target.path)?;
let path_str = validated_path.to_string_lossy().to_string();
assert_editable_file(Path::new(&path_str))?;
let hashline_input = format!(
"{prefix}{path}#{hash}\n{ops}",
prefix = HL_FILE_PREFIX,
path = target.path,
hash = target.file_hash,
ops = target.ops,
);
let patch = Patch::parse(&hashline_input, &SplitOptions::default()).map_err(|e| {
enrich_edit_error(
&target.path,
&path_str,
&format!(
"failed to parse edit operations for `{}`: {}",
target.path, e
),
)
})?;
let session_store = rollback::session_store().clone();
let mut patcher = Patcher::new_shared(
DiskFilesystem::new(),
session_store,
Some(resolve_block_fn as BlockResolver),
);
let prepared = patcher
.prepare(&patch.sections[0])
.await
.map_err(|e| enrich_edit_error(&target.path, &path_str, &e.to_string()))?;
if let Some(expected) = expected_after
&& prepared.apply_result.text != expected
{
return Err(enrich_edit_error(
&target.path,
&path_str,
&format!(
"content replace for `{}` deviated from the exact replacement: the \
hashline engine's boundary/indent repair altered the payload. \
Re-read the touched region and reissue with the `targets` engine \
if the repaired form is acceptable.",
target.path
),
));
}
if dry_run {
let after = &prepared.apply_result.text;
let mut warnings = merge_warnings(&[
Some(&prepared.parse_warnings),
Some(&prepared.apply_result.warnings),
]);
warnings.push(DRY_RUN_WARNING.to_string());
let hash = compute_file_hash(after);
return Ok(EditResult {
path: target.path.clone(),
file_hash: hash.clone(),
header: format_hashline_header(&target.path, &hash),
first_changed_line: prepared.apply_result.first_changed_line,
warnings,
lsp_notes: None,
dry_run: Some(true),
diff: if *after == prepared.normalized {
None
} else {
Some(
structured_patch(&prepared.normalized, after, 3)
.to_unified_diff(&target.path, &target.path),
)
},
});
}
let _ = rollback::record(&path_str, &prepared.normalized);
let section = patcher
.commit(prepared)
.await
.map_err(|e| enrich_edit_error(&target.path, &path_str, &e.to_string()))?;
cosh_sdk::tree_sitter::tree_sitter().invalidate(&path_str);
let diff = if section.before == section.after {
None
} else {
Some(
structured_patch(§ion.before, §ion.after, 3)
.to_unified_diff(&target.path, &target.path),
)
};
Ok(EditResult {
path: target.path.clone(),
file_hash: section.file_hash,
header: section.header,
first_changed_line: section.first_changed_line,
warnings: section.warnings,
lsp_notes: None,
dry_run: None,
diff,
})
}