mod anchors;
mod apply;
mod metering;
mod output;
mod symbol;
#[cfg(test)]
mod tests;
pub use anchors::AnchorOp;
pub(crate) use symbol::{build_refactor_args, is_replace_symbol};
use std::path::{Path, PathBuf};
use crate::tools::ctx_edit::{CacheEffect, apply_cache_effect, build_diff_evidence};
use crate::tools::edit_io::{
default_backup_path, ensure_preimage_still_matches, read_preimage,
write_atomic_bytes_with_permissions,
};
use lean_ctx::core::cache::SessionCache;
pub struct PatchParams {
pub path: String,
pub ops: Vec<AnchorOp>,
pub expected_blake3: Option<String>,
pub backup: bool,
pub backup_path: Option<String>,
pub evidence: bool,
pub diff_artifact: bool,
pub diff_max_lines: usize,
pub allow_lossy_utf8: bool,
pub validate_syntax: bool,
}
pub fn parse_ops(
args: &serde_json::Map<String, serde_json::Value>,
) -> Result<Vec<AnchorOp>, String> {
anchors::parse_ops(args)
}
pub fn handle(cache: &mut SessionCache, params: &PatchParams) -> String {
let last_mode = cache
.get(¶ms.path)
.map(|e| e.last_mode.clone())
.unwrap_or_default();
let (text, effect) = run_io(params, &last_mode);
record_outcome(params, &last_mode, &text, &effect);
apply_cache_effect(cache, ¶ms.path, effect);
text
}
pub fn record_outcome(params: &PatchParams, last_mode: &str, text: &str, effect: &CacheEffect) {
let success = matches!(effect, CacheEffect::Invalidate);
let conflict = matches!(effect, CacheEffect::None) && text.starts_with("CONFLICT:");
if success || conflict {
lean_ctx::core::edit_quality::record_anchored_edit_outcome(
¶ms.path,
last_mode,
success,
);
}
}
pub fn run_io(params: &PatchParams, _last_mode: &str) -> (String, CacheEffect) {
let file_path = ¶ms.path;
let path = Path::new(file_path);
let cap = lean_ctx::core::limits::max_read_bytes();
if let Some(content) = single_create_op(¶ms.ops) {
return match content {
Ok(text) => handle_create(params, path, text),
Err(e) => (e, CacheEffect::None),
};
}
let pre = match read_preimage(path, cap, params.allow_lossy_utf8) {
Ok(p) => p,
Err(e) => {
if !path.exists() {
let hint = crate::tools::edit_recovery::moved_or_deleted_hint(path);
return (format!("{e}{hint}"), CacheEffect::None);
}
return (e, CacheEffect::None);
}
};
if let Some(expected) = params.expected_blake3.as_deref()
&& expected != pre.fp.blake3
{
return (
format!(
"ERROR: preimage mismatch for {file_path}: expected_blake3={expected}, actual_blake3={}",
pre.fp.blake3
),
CacheEffect::None,
);
}
if params.ops.is_empty() {
return (
"ERROR: no edits provided (pass an op or ops:[…])".to_string(),
CacheEffect::None,
);
}
let (bom, body) = match pre.text.strip_prefix('\u{feff}') {
Some(rest) => ("\u{feff}", rest),
None => ("", pre.text.as_str()),
};
let (lines, sep, trailing) = apply::split_lines(body);
let edits = match apply::resolve_ops(&lines, ¶ms.ops) {
Ok(e) => e,
Err(apply::ResolveError::Conflict(misses)) => {
lean_ctx::core::edit_metering::record_anchored_conflict();
return (
output::render_conflict(file_path, &lines, &misses),
CacheEffect::None,
);
}
Err(apply::ResolveError::Invalid(msg)) => {
return (format!("ERROR: {msg}"), CacheEffect::None);
}
};
let avoided_tokens = metering::avoided_output_tokens(&lines, ¶ms.ops);
let n_edits = edits.len();
let net_change = edits.iter().fold((0u64, 0u64), |(added, removed), edit| {
let (a, r) = edit.net_change(&lines);
(added.saturating_add(a), removed.saturating_add(r))
});
let lines_added = edits.iter().fold(0u64, |total, edit| {
total.saturating_add(u64::try_from(edit.new_lines.len()).unwrap_or(u64::MAX))
});
let lines_removed = edits.iter().fold(0u64, |total, edit| {
total.saturating_add(u64::try_from(edit.remove_count).unwrap_or(u64::MAX))
});
let new_lines = apply::apply_edits(lines.clone(), edits);
let new_content = format!("{bom}{}", apply::join_lines(&new_lines, sep, trailing));
if new_content == pre.text {
return (
"ERROR: edits produced no change to the file".to_string(),
CacheEffect::None,
);
}
let ext = Path::new(file_path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
if params.validate_syntax
&& let Some(reason) =
lean_ctx::core::syntax_validate::gate_edit(ext, &pre.text, &new_content)
{
return (reason, CacheEffect::None);
}
let health_notice =
match lean_ctx::core::code_health::gate::evaluate(&pre.text, &new_content, ext) {
lean_ctx::core::code_health::gate::GateOutcome::Block(reason) => {
return (
format!("ERROR: code-health gate: {reason}"),
CacheEffect::None,
);
}
lean_ctx::core::code_health::gate::GateOutcome::Allow(notice) => notice,
};
if let Err(e) = ensure_preimage_still_matches(path, &pre.fp, cap) {
return (e, CacheEffect::None);
}
let backup_path = match make_backup(params, path, &pre.bytes, &pre.permissions) {
Ok(bp) => bp,
Err(e) => return (e, CacheEffect::None),
};
if let Err(e) =
write_atomic_bytes_with_permissions(path, new_content.as_bytes(), Some(&pre.permissions))
{
return (e, CacheEffect::None);
}
lean_ctx::core::edit_metering::record_loc_change(Some(file_path), lines_added, lines_removed);
if let Ok(mut bt) = lean_ctx::core::bounce_tracker::global().lock() {
bt.record_edit(file_path);
}
lean_ctx::core::edit_metering::record_anchored_success(n_edits as u64, avoided_tokens);
let mut out = render_success(
params,
&pre,
&new_content,
new_lines.len(),
net_change,
n_edits,
backup_path,
);
if let Some(notice) = health_notice {
out.push_str("\n\n");
out.push_str(¬ice);
}
(out, CacheEffect::Invalidate)
}
fn single_create_op(ops: &[AnchorOp]) -> Option<Result<&str, String>> {
let create = ops.iter().find_map(|op| match op {
AnchorOp::Create { new_text } => Some(new_text.as_str()),
_ => None,
})?;
if ops.len() > 1 {
return Some(Err(
"ERROR: create cannot be batched with anchored ops — a new file has no \
preimage to anchor against; send create as a single op"
.to_string(),
));
}
Some(Ok(create))
}
fn handle_create(params: &PatchParams, path: &Path, content: &str) -> (String, CacheEffect) {
if path.exists() {
return (
format!(
"ERROR: {} already exists — create is for new files only. \
Use anchored ops (ctx_read mode=\"anchored\" → set_line/replace_lines) to modify it.",
params.path
),
CacheEffect::None,
);
}
if let Err(e) = lean_ctx::core::pathjail::enforce_writable(path) {
return (format!("ERROR: {e}"), CacheEffect::None);
}
if let Some(parent) = path.parent()
&& !parent.exists()
&& let Err(e) = std::fs::create_dir_all(parent)
{
return (
format!("ERROR: cannot create directory {}: {e}", parent.display()),
CacheEffect::None,
);
}
if let Err(e) = write_atomic_bytes_with_permissions(path, content.as_bytes(), None) {
return (e, CacheEffect::None);
}
let lines_added = u64::try_from(content.lines().count()).unwrap_or(u64::MAX);
lean_ctx::core::edit_metering::record_loc_change(Some(¶ms.path), lines_added, 0);
if let Ok(mut bt) = lean_ctx::core::bounce_tracker::global().lock() {
bt.record_edit(¶ms.path);
}
let lines = content.lines().count();
let short = output::short_name(¶ms.path);
let mut out = format!("created {short}: {lines} lines");
if params.evidence {
out.push_str(&format!(
"\npostimage: bytes={}, blake3={}",
content.len(),
lean_ctx::core::hasher::hash_hex(content.as_bytes())
));
let diff = build_diff_evidence("", content, &short, params.diff_max_lines);
out.push_str("\n\nevidence (diff, redacted, bounded):\n```diff\n");
out.push_str(&diff);
out.push_str("\n```");
} else if params.diff_artifact
&& let Some(trailer) = crate::tools::ctx_edit::diff_artifact_trailer(
"",
content,
&short,
params.diff_max_lines,
)
{
out.push('\n');
out.push_str(&trailer);
}
(out, CacheEffect::Invalidate)
}
fn make_backup(
params: &PatchParams,
path: &Path,
bytes: &[u8],
permissions: &std::fs::Permissions,
) -> Result<Option<String>, String> {
if !params.backup {
return Ok(None);
}
let bp = params
.backup_path
.as_deref()
.map(PathBuf::from)
.or_else(|| default_backup_path(path))
.ok_or_else(|| format!("ERROR: cannot compute backup path for {}", path.display()))?;
write_atomic_bytes_with_permissions(&bp, bytes, Some(permissions))
.map_err(|e| format!("ERROR: cannot create backup {}: {e}", bp.display()))?;
Ok(Some(bp.to_string_lossy().to_string()))
}
fn render_success(
params: &PatchParams,
pre: &crate::tools::edit_io::FilePreimage,
new_content: &str,
lines_after: usize,
(lines_added, lines_removed): (u64, u64),
n_edits: usize,
backup_path: Option<String>,
) -> String {
let short = output::short_name(¶ms.path);
let detail = (n_edits > 1).then(|| format!("{n_edits} edits"));
let mut out = crate::tools::ctx_edit::edit_summary_line(
&short,
lines_after,
lines_added,
lines_removed,
detail.as_deref(),
);
if let Some(bp) = backup_path {
out.push_str(&format!("\nbackup: {bp}"));
}
if params.evidence {
let post_mtime_ms = std::fs::metadata(¶ms.path)
.ok()
.and_then(|m| m.modified().ok())
.map_or(0, crate::tools::edit_io::system_time_to_millis);
out.push_str(&format!(
"\npreimage: bytes={}, mtime_ms={}, blake3={}\n\
postimage: bytes={}, mtime_ms={post_mtime_ms}, blake3={}",
pre.fp.size,
pre.fp.mtime_ms,
pre.fp.blake3,
new_content.len(),
lean_ctx::core::hasher::hash_hex(new_content.as_bytes())
));
if params.ops.len() > 1 {
out.push('\n');
out.push_str(&render_op_ledger(¶ms.ops));
}
let old_content = pre.text.as_str();
let diff = build_diff_evidence(old_content, new_content, &short, params.diff_max_lines);
out.push_str("\n\nevidence (diff, redacted, bounded):\n```diff\n");
out.push_str(&diff);
out.push_str("\n```");
let balance = brace_balance(new_content);
if balance == 0 {
out.push_str("\nbrace-balance: ok (matched)");
} else {
out.push_str(&format!(
"\n⚠ brace-balance: {} unmatched '{{' — verify file integrity",
balance.abs()
));
}
} else if params.diff_artifact
&& let Some(trailer) = crate::tools::ctx_edit::diff_artifact_trailer(
&pre.text,
new_content,
&short,
params.diff_max_lines,
)
{
out.push('\n');
out.push_str(&trailer);
}
out
}
fn render_op_ledger(ops: &[AnchorOp]) -> String {
let mut out = format!("ops: {n}/{n} applied", n = ops.len());
for (index, op) in ops.iter().enumerate() {
let (kind, span, produced) = match op {
AnchorOp::SetLine { line, new_text, .. } => {
("set_line", line.to_string(), produced_lines(new_text))
}
AnchorOp::ReplaceLines {
start_line,
end_line,
new_text,
..
} => (
"replace_lines",
format!("{start_line}-{end_line}"),
produced_lines(new_text),
),
AnchorOp::InsertAfter { line, new_text, .. } => {
("insert_after", line.to_string(), produced_lines(new_text))
}
AnchorOp::Delete {
start_line,
end_line,
..
} => ("delete", format!("{start_line}-{end_line}"), 0),
AnchorOp::Create { new_text } => ("create", "-".to_string(), produced_lines(new_text)),
};
let line_word = if produced == 1 { "line" } else { "lines" };
out.push_str(&format!(
"\n {}. {kind} {span} → {produced} {line_word}",
index + 1
));
}
out
}
fn produced_lines(new_text: &str) -> usize {
if new_text.is_empty() {
0
} else {
new_text.lines().count()
}
}
fn brace_balance(content: &str) -> i64 {
content.chars().fold(0i64, |acc, c| match c {
'{' => acc + 1,
'}' => acc - 1,
_ => acc,
})
}