Skip to main content

bekoedit_markdown/
patch.rs

1//! The SourcePatch engine (RFC-015): the only path through which canonical
2//! Markdown text is mutated.
3//!
4//! Every patch is revision-checked and UTF-8-boundary-validated before
5//! application, making invalid mutations impossible by construction
6//! (reliability rule REL-103).
7
8use serde::{Deserialize, Serialize};
9
10use crate::range::{ByteRange, RangeError};
11
12/// Where a patch originated (external design §23.12).
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14pub enum PatchOrigin {
15    TextMode,
16    FormMode,
17    RawIsland,
18    FileRecovery,
19}
20
21/// A Rust-approved mutation of a byte range in canonical text (RFC-015 §7).
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct SourcePatch {
24    /// Document revision the patch was computed against.
25    pub base_revision: u64,
26    pub range: ByteRange,
27    pub replacement: String,
28    pub origin: PatchOrigin,
29}
30
31/// Result of a successfully applied patch (RFC-015 §7).
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct PatchResult {
34    /// Range covered by the replacement text in the new document.
35    pub affected_range: ByteRange,
36    /// Full reparse is always required in the MVP strategy.
37    pub reparse_required: bool,
38}
39
40/// Structured patch rejection reasons (requirements §23.3).
41#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, Serialize, Deserialize)]
42pub enum PatchError {
43    #[error("document revision mismatch: patch base {base}, current {current}")]
44    DocumentRevisionMismatch { base: u64, current: u64 },
45    #[error("invalid patch range: {0}")]
46    InvalidRange(#[from] RangeError),
47}
48
49/// Validates and applies `patch` to `text`, which must be at revision
50/// `current_revision`. On success the caller increments the document
51/// revision and triggers a full reparse.
52pub fn apply_patch(
53    text: &mut String,
54    current_revision: u64,
55    patch: &SourcePatch,
56) -> Result<PatchResult, PatchError> {
57    if patch.base_revision != current_revision {
58        return Err(PatchError::DocumentRevisionMismatch {
59            base: patch.base_revision,
60            current: current_revision,
61        });
62    }
63    patch.range.validate(text)?;
64    text.replace_range(patch.range.start..patch.range.end, &patch.replacement);
65    Ok(PatchResult {
66        affected_range: ByteRange::new(
67            patch.range.start,
68            patch.range.start + patch.replacement.len(),
69        ),
70        reparse_required: true,
71    })
72}