// The addressed run-replacement boundary (#462 A1): the ONE public seam
// through which a shipped surface reaches N0c1. The reader-native
// projection, its contribution types, and the raw byte edits stay
// private; what crosses the boundary is a source-pinned SplicePlan plus
// a small receipt. The resolver trusts only the retained projection --
// its own scan resolves the address, its run records decide ownership --
// so the annotation index never becomes a mutation authority.
///|
/// What an addressed run replacement planned: the story part, the
/// address that named the run, the reader-visible text the caller
/// expected (and the plan replaces), and how many byte edits carry it.
pub struct RunTextReceipt {
priv story_part : String
priv at_relative : String
priv replaced_text : String
priv result_text : String
priv edit_count : Int
}
///|
/// The story part the addressed run lives in, spelled exactly as the
/// annotated read exposed it (for example `word/document.xml`). It is the
/// only part the returned plan targets: the plan pins it, and any edit the
/// plan carries is an edit to it. A self-replacement carries none.
pub fn RunTextReceipt::story_part(self : RunTextReceipt) -> String {
self.story_part
}
///|
/// The story-relative ordinal run path that named the run (for example
/// `p[1]/r[2]`), echoed back once it has resolved to exactly one run in
/// this story -- unique among the scan's run nodes AND among the projected
/// runs. Against this same snapshot it stays valid, so a caller may re-plan
/// with `expect=replaced_text()`. Once the plan is APPLIED the address is
/// only meaningful again after a fresh mutation-safe read, since ordinals
/// are snapshot-relative.
pub fn RunTextReceipt::at_relative(self : RunTextReceipt) -> String {
self.at_relative
}
///|
/// The `expect` string the caller passed, returned only after it was
/// checked equal to the run's reader-visible text. So this is both what
/// the caller asserted and what the run actually held: a receipt exists
/// only when those agree.
pub fn RunTextReceipt::replaced_text(self : RunTextReceipt) -> String {
self.replaced_text
}
///|
/// The `text` the addressed run holds once this plan is applied to the
/// package it was built from: reading that result back through the reader
/// reproduces exactly this for that run. The plan pins only the story
/// part, so applying it to a different archive that happens to carry the
/// same story bytes is not covered by that claim. When `result_text`
/// equals `replaced_text` the run already holds it and the plan is empty.
pub fn RunTextReceipt::result_text(self : RunTextReceipt) -> String {
self.result_text
}
///|
/// How many byte edits the plan carries. It is a size, not a count of
/// runs or characters -- one addressed run can need several edits. Zero
/// means the replacement was the run's own text: the address, the
/// expectation and the run's writability were all still checked, and the
/// plan edits nothing rather than re-encoding bytes that already say it.
pub fn RunTextReceipt::edit_count(self : RunTextReceipt) -> Int {
self.edit_count
}
///|
/// Plans the replacement of one addressed run's whole text with `text`.
///
/// `story` must be a story part source the annotated result itself
/// exposed, and the result must come from a MUTATION-SAFE read: only
/// those retain the classified projection this planner consumes.
/// `at_relative` is the story-relative ordinal run path (`p[3]/r[2]`),
/// resolved against the projection's own physical scan. `expect` is
/// REQUIRED and must equal the run's reader-visible text: addresses are
/// snapshot-relative, and a stale expectation refuses rather than
/// editing whatever now sits at the path.
///
/// Every failure is a typed refusal; nothing falls back. The planner
/// takes NO archive: it plans against the exact source bytes the
/// annotated read retained beside the projection, so a mismatched
/// archive cannot be smuggled in -- and the returned plan is pinned to
/// those same bytes, so applying it to anything else refuses as stale.
pub fn plan_run_text_replacement(
annotated : DocxAnnotatedResult,
story : DocxStoryPartSource,
at_relative~ : String,
expect~ : String,
text~ : String,
) -> (@splice.SplicePlan, RunTextReceipt) raise DocxError {
let part = story.part()
guard annotated.reader_projections.get(part) is Some(projection) else {
raise Unsupported(
message="run replacement requires a mutation-safe read with a retained projection for '\{part}'",
)
}
// Resolve the address against the projection's OWN scan. Scanner
// ordinal paths are unique per story; a duplicate would mean the scan
// itself is inconsistent, which must refuse, not pick.
let mut addressed_start = -1
let mut run_nodes = 0
for node in projection.scan.nodes() {
if node.kind == "r" {
run_nodes += 1
if node.path == at_relative {
if addressed_start >= 0 {
raise Unsupported(
message="'\{at_relative}' names more than one run in '\{part}'",
)
}
addressed_start = node.byte_start
}
}
}
if addressed_start < 0 {
raise Unsupported(
message="'\{at_relative}' does not name a run in '\{part}' (\{run_nodes} runs are addressable)",
)
}
// Map the physical run to exactly one projection paragraph/run. The
// planner re-checks cross-paragraph reuse; refusing here keeps the
// boundary's own contract total.
let elements = projection.scan.elements()
let mut located : (Int, Int)? = None
for paragraph_index, paragraph in projection.paragraphs {
for run_index, run in paragraph.runs {
let SourceElementId(identity) = run.source
if elements[identity].byte_start == addressed_start {
if located is Some(_) {
raise Unsupported(
message="'\{at_relative}' maps to more than one projected run in '\{part}'",
)
}
located = Some((paragraph_index, run_index))
}
}
}
guard located is Some((paragraph_index, run_index)) else {
raise Unsupported(
message="'\{at_relative}' names a run the mutation projection does not own in '\{part}'",
)
}
// The stale-address gate: the run's reader-visible text must be what
// the caller believes it is. Neither side's content is echoed into
// the error -- refusal reasons must not leak document text into logs.
let target = projection.paragraphs[paragraph_index].runs[run_index].source
let visible = StringBuilder()
for contribution in projection.paragraphs[paragraph_index].contributions {
if contribution.run_source == Some(target) {
visible.write_string(contribution.value)
}
}
if visible.to_string() != expect {
raise Unsupported(
message="the run at '\{at_relative}' in '\{part}' does not carry the expected text; re-read the document and retry with a fresh address",
)
}
guard annotated.reader_projection_sources.get(part) is Some(bytes) else {
raise Unsupported(
message="the mutation-safe read retained no source bytes for '\{part}'",
)
}
// The surgery runs FIRST, even when the text is unchanged: a no-op
// suppresses byte EDITING, never VALIDATION. Returning early on
// `text == expect` reported success for runs the surgery would have
// refused, so a caller could not learn a run was unwritable by
// writing what it already said.
//
// The surgery runs in VALIDATE-ONLY mode for a self-replacement: the
// same walk and the same refusals, with the edit records and any
// synthesised run body left unbuilt and no sort. The replacement is
// still escaped, because that is where its own validity is decided. That keeps the refusal rules in ONE copy -- a
// second, validation-shaped copy of them would drift, and a drifting
// refusal rule is how a surgery starts accepting what it documents as
// refused.
let self_replacement = text == expect
let edits = plan_whole_run_replacement(
projection,
bytes,
paragraph_index,
run_index,
text,
validate_only=self_replacement,
)
// Replacing text with itself EDITS nothing: re-encoding could still
// change bytes (entities re-escape), so the only honest no-op is an
// empty plan -- after the address, the expectation, and the run's own
// writability have all been established.
if self_replacement {
let noop = @splice.SplicePlan::new()
noop.pin_part(part, bytes)
return (
noop,
{
story_part: part,
at_relative,
replaced_text: expect,
result_text: text,
edit_count: 0,
},
)
}
let plan = @splice.SplicePlan::new()
plan.pin_part(part, bytes)
for edit in edits {
plan.edit_part(
part,
@splice.span_edit(
start=edit.byte_start,
end=edit.byte_end,
@utf8.encode(edit.replacement),
),
)
}
(
plan,
{
story_part: part,
at_relative,
replaced_text: expect,
result_text: text,
edit_count: edits.length(),
},
)
}