patcher — validating and applying a patch to real files
The Patcher is the only stage of the hashline pipeline that touches the
filesystem. It wires everything together: read the section’s target file
through a Filesystem, strip the BOM and normalize line endings,
validate the section’s snapshot tag (with recovery), apply
the edits in memory, and write the result back — restoring the original BOM
and line endings.
let mut patcher = Patcher::new(
DiskFilesystem::new(),
InMemorySnapshotStore::new(&Default::default()),
None, // optional BlockResolver for `replace block N:`
);
let result = patcher.apply(&patch).await?;
The Patcher is stateless across calls — construct one per filesystem
configuration and reuse it.
The two layers
| API | Behavior |
|---|---|
Patcher::apply(&patch) |
High-level, all-or-nothing. Preflights every section in memory before any write hits disk, then commits in order. |
Patcher::prepare(§ion) → PreparedSection |
The read-side work only: read, parse, validate, apply in memory. Returns a prepared token. |
Patcher::commit(prepared) |
Writes a prepared result, restores line endings/BOM, records a fresh snapshot. |
Patcher::preflight(&patch) |
The preflight pass only — no writes. Use for CI checks and dry runs. |
Because prepare already runs the full apply, a multi-file batch is
naturally all-or-nothing: by the time any commit runs, every section has
been validated. A section whose apply produced no text change aborts the
batch with Edits to {path} resulted in no changes being made. — this fires
in the multi-section path (different target files); a single section (or
same-path sections, which merge into one) short-circuits to the fast path
and reports the section with op: Noop instead of failing.
Two constructors exist: Patcher::new(fs, store, resolver) wraps the store
in its own Arc<Mutex<S>>; Patcher::new_shared(fs, arc_store, resolver)
accepts an already-shared store — use it when the patcher must record
snapshots into the same store other session tools use (e.g. the global
rollback::session_store).
The prepare contract
prepare (and therefore apply) enforces, in order:
- The section must carry a hash tag. A missing tag is a hard error:
Missing hashline snapshot tag for edit to {path}; use ¶{path}#tag from your latest read/search output. To create a new file, use the write tool. - The file must exist. A missing target errors:
File not found: {path}. Use the write tool to create new files.(Hashline edits existing files;writecreates.) - The tag must match the live content — see below.
- No two sections may resolve to the same canonical path:
Multiple hashline sections resolve to the same file (A and B). Merge their ops under one header before applying.
Tag validation: the heart of the safety model
The section’s tag is a fingerprint of the file as the model saw it. At apply time the patcher compares it against a hash of the live file:
| Live content vs. tag | Behavior |
|---|---|
| Matches (or no tag needed) | Apply directly. The file is exactly what the model read. |
| Stale, but only head/tail inserts | Apply onto the live content with a warning: Applied an \insert head:`/`insert tail:` edit onto the current file content even though the snapshot tag was stale … re-read if the drift was unexpected.` Head/tail position is content-independent, so the insert is safe. |
| Stale, anchored edit, snapshot recorded | Attempt recovery: replay the edit against the recorded snapshot and 3-way-merge onto the live content. Success yields a recovery banner warning. |
| Stale, anchored edit, no recovery | Reject with a MismatchError: Edit rejected: file changed between read and edit. |
The mismatch rejection is rich on purpose — it re-reads nothing, but it carries the expected/actual tags, the anchored lines, and the surrounding context, and it distinguishes two cases:
- Hash recognized (a snapshot exists for it — the file drifted since):
Section is bound to #EXPECTED, but the current file hashes to #ACTUAL. If a prior edit in this session modified this file, copy the ¶path#newhash header from that edit's response; otherwise re-read the file with \read` to refresh the tag before retrying.` - Hash not recognized (never recorded — fabricated or carried over from
a prior session):
Edit rejected: hash #TAG is not from this session. … never invent the tag and never reuse one from a prior session.
The result
Each committed section yields a PatchSectionResult:
pub struct PatchSectionResult {
pub path: String,
pub canonical_path: String,
pub op: PatchOp, // Create | Update | Noop
pub before: String, // LF-normalized pre-edit text
pub after: String, // LF-normalized post-edit text
pub persisted: String, // after, with original BOM + line endings
pub written: String, // what the Filesystem actually stored
pub file_hash: String, // fresh 4-hex tag for the new content
pub header: String, // "¶path#tag" for follow-up edits
pub first_changed_line: Option<u32>,
pub warnings: Vec<String>,
}
header/file_hash are the anchor for the next edit: an agent applies an
edit, reads header from the response, and anchors the follow-up against it
— that’s how edit chains stay valid.