Skip to main content

documents/
proposal.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5// Portions copyright (c) 2026 2youg1 and the RefRain contributors
6
7//! One suggestion a run makes about one stretch of a document version,
8//! read as a card of sentences and decided sentence by sentence
9//! (`crates/documents/spec/Proposal.lean`, D13-D19).
10//!
11//! Migrated from RefRain's `manuscript::review` (a proposal frozen on a
12//! baseline, cut into review slices) and `manuscript::decision` (verdicts
13//! per slice, a batch refused when it is stale or its scopes overlap).
14//! The identity is a digest rather than a random id, the scope is a
15//! stretch of bytes rather than a list of blocks, and several cards land
16//! as one save through [`crate::save`]'s own rules.
17
18use std::collections::{BTreeMap, BTreeSet};
19
20use kernel::event::record::{ProposalOffered, SliceVerdict, Verdict};
21use kernel::{Address, AxCode, AxError, B3Hash, RunId};
22use serde::{Deserialize, Serialize};
23
24use crate::edit::{Applied, TextEdit, saved_on};
25use crate::encoding::Reading;
26use crate::span::{Span, offset};
27use crate::window::WINDOW_BYTES_MAX;
28
29mod slices;
30
31pub use slices::ALIGN_CELLS_MAX;
32
33/// What a proposal's identity digest begins with, so it is never the
34/// digest of some document that happens to hold the same bytes (D13).
35pub const PROPOSAL_ID_TAG: &[u8] = b"sprawling proposal v1\0";
36
37/// The role one sentence plays on a card.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
41pub enum SliceKind {
42    /// On both sides.
43    Same,
44    /// In the original only.
45    Delete,
46    /// In the suggestion only.
47    Insert,
48}
49
50/// One sentence of a card with the whitespace around it; `lead`,
51/// `text` and `trail` read in order are the sentence's bytes (D14).
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
54pub struct Slice {
55    pub kind: SliceKind,
56    pub text: String,
57    pub lead: String,
58    pub trail: String,
59}
60
61impl Slice {
62    fn bytes_onto(&self, text: &str, out: &mut String) {
63        out.push_str(&self.lead);
64        out.push_str(text);
65        out.push_str(&self.trail);
66    }
67}
68
69/// The card two texts make: their sentences aligned in reading order.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct Review {
72    slices: Vec<Slice>,
73}
74
75impl Review {
76    /// Cuts both texts into sentences and aligns them (D14, D15).
77    pub fn of(before: &str, after: &str) -> Review {
78        Review {
79            slices: slices::align(slices::pieces(before), slices::pieces(after)),
80        }
81    }
82
83    pub fn slices(&self) -> &[Slice] {
84        &self.slices
85    }
86
87    pub fn into_slices(self) -> Vec<Slice> {
88        self.slices
89    }
90
91    /// The text a person's verdicts make of the card: a changed sentence
92    /// the verdicts do not name is rejected (D16).
93    ///
94    /// # Errors
95    /// `E_INVALID_ARGS` naming the slice, for a place no sentence holds,
96    /// a sentence that did not change, one named twice, and an amended
97    /// deletion.
98    pub fn merged(&self, verdicts: &[SliceVerdict]) -> Result<String, AxError> {
99        let mut named: BTreeMap<u32, &Verdict> = BTreeMap::new();
100        for verdict in verdicts {
101            if named.insert(verdict.slice, &verdict.verdict).is_some() {
102                return Err(refused(verdict.slice, "is named twice"));
103            }
104        }
105        let mut out = String::new();
106        for (place, slice) in (0_u32..).zip(&self.slices) {
107            match (slice.kind, named.remove(&place)) {
108                (SliceKind::Same, Some(Verdict::Accept | Verdict::Amend { .. })) => {
109                    return Err(refused(place, "did not change"));
110                }
111                (SliceKind::Delete, Some(Verdict::Amend { .. })) => {
112                    return Err(refused(
113                        place,
114                        "is a deletion, and only an insertion is amended",
115                    ));
116                }
117                (SliceKind::Same | SliceKind::Delete, None)
118                | (SliceKind::Insert, Some(Verdict::Accept)) => {
119                    slice.bytes_onto(&slice.text, &mut out)
120                }
121                (SliceKind::Insert, Some(Verdict::Amend { text })) => {
122                    slice.bytes_onto(text, &mut out)
123                }
124                (SliceKind::Delete, Some(Verdict::Accept)) | (SliceKind::Insert, None) => {}
125            }
126        }
127        match named.into_keys().next() {
128            Some(place) => Err(refused(place, "is not on this card")),
129            None => Ok(out),
130        }
131    }
132}
133
134/// One proposal as the Ledger offered it: who offered it, about which
135/// stretch of which version, and the two texts (D13, D18).
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137pub struct Offer {
138    run: RunId,
139    doc: Address,
140    baseline: B3Hash,
141    span: Span,
142    before: String,
143    after: String,
144}
145
146impl Offer {
147    /// Reads one `proposal_offered` line written by `run`.
148    ///
149    /// # Errors
150    /// `E_INVALID_ARGS` for a reversed span, and for an original or a
151    /// suggestion longer than `WINDOW_BYTES_MAX` (D18).
152    pub fn of(run: RunId, offered: &ProposalOffered) -> Result<Offer, AxError> {
153        let span = Span::new(offered.start, offered.end)?;
154        for (side, text) in [
155            ("original", &offered.before),
156            ("suggestion", &offered.after),
157        ] {
158            if offset(text.len()) > WINDOW_BYTES_MAX {
159                return Err(AxError::failure(
160                    AxCode::InvalidArgs,
161                    "offer a proposal",
162                    format!(
163                        "the {side} is {} bytes, past {WINDOW_BYTES_MAX}",
164                        text.len()
165                    ),
166                )
167                .with_recovery("offer a shorter stretch, or several proposals"));
168            }
169        }
170        Ok(Offer {
171            run,
172            doc: offered.doc.clone(),
173            baseline: offered.baseline,
174            span,
175            before: offered.before.clone(),
176            after: offered.after.clone(),
177        })
178    }
179
180    /// The proposal's identity: a digest of the run, the document, the
181    /// version, the stretch and both texts (D13).
182    pub fn id(&self) -> B3Hash {
183        let mut bytes = Vec::with_capacity(
184            PROPOSAL_ID_TAG
185                .len()
186                .saturating_add(self.before.len())
187                .saturating_add(self.after.len())
188                .saturating_add(self.doc.as_str().len())
189                .saturating_add(96),
190        );
191        bytes.extend_from_slice(PROPOSAL_ID_TAG);
192        bytes.extend_from_slice(self.run.as_bytes());
193        for text in [self.doc.as_str(), &self.before, &self.after] {
194            bytes.extend_from_slice(&offset(text.len()).to_be_bytes());
195            bytes.extend_from_slice(text.as_bytes());
196        }
197        bytes.extend_from_slice(self.baseline.as_bytes());
198        bytes.extend_from_slice(&self.span.start().to_be_bytes());
199        bytes.extend_from_slice(&self.span.end().to_be_bytes());
200        B3Hash::digest(&bytes)
201    }
202
203    pub fn run(&self) -> RunId {
204        self.run
205    }
206
207    pub fn doc(&self) -> &Address {
208        &self.doc
209    }
210
211    pub fn baseline(&self) -> B3Hash {
212        self.baseline
213    }
214
215    pub fn span(&self) -> Span {
216        self.span
217    }
218
219    pub fn before(&self) -> &str {
220        &self.before
221    }
222
223    pub fn after(&self) -> &str {
224        &self.after
225    }
226
227    /// The card this proposal reads as.
228    pub fn review(&self) -> Review {
229        Review::of(&self.before, &self.after)
230    }
231}
232
233/// Decides several cards on one document at once: what their verdicts
234/// accept lands on `source` as one save, and `None` when they accept
235/// nothing (D17).
236///
237/// # Errors
238/// `E_INVALID_ARGS` for a card named twice, for verdicts
239/// [`Review::merged`] refuses, for a card whose original is not the
240/// stretch of `source` it names, and for accepted stretches that
241/// overlap. `E_VERSION_CONFLICT` when a card that accepts anything was
242/// made on another version than `source`. Nothing lands in any case.
243pub fn decide(
244    source: &[u8],
245    cards: &[(&Offer, &[SliceVerdict])],
246) -> Result<Option<Applied>, AxError> {
247    let mut seen = BTreeSet::new();
248    let mut edits = Vec::new();
249    for (offer, verdicts) in cards {
250        if !seen.insert(offer.id()) {
251            return Err(AxError::failure(
252                AxCode::InvalidArgs,
253                "decide proposals",
254                format!("proposal {} is named twice", offer.id()),
255            )
256            .with_recovery("name each card once, with all its verdicts"));
257        }
258        let text = offer.review().merged(verdicts)?;
259        if text != offer.before {
260            edits.push((*offer, text));
261        }
262    }
263    let Some((first, _)) = edits.first() else {
264        return Ok(None);
265    };
266    let version = B3Hash::digest(source);
267    if let Some((stale, _)) = edits.iter().find(|(offer, _)| offer.baseline != version) {
268        return Err(AxError::failure(
269            AxCode::VersionConflict,
270            "decide proposals",
271            format!(
272                "proposal {} was made on version {}, and the document is now {version}",
273                stale.id(),
274                stale.baseline
275            ),
276        )
277        .with_recovery(
278            "reject the card, or ask the run to offer it again on what the document says now",
279        ));
280    }
281    let Reading::Text(encoding) = Reading::of(source) else {
282        return Err(unmatched(first));
283    };
284    for (offer, _) in &edits {
285        let stated = offer
286            .span
287            .within(source.len())
288            .and_then(|(start, end)| source.get(start..end))
289            .map(|bytes| encoding.decode(bytes));
290        if !matches!(stated, Some(Ok(ref text)) if *text == offer.before) {
291            return Err(unmatched(offer));
292        }
293    }
294    let mut edits: Vec<TextEdit> = edits
295        .into_iter()
296        .map(|(offer, text)| TextEdit {
297            span: offer.span,
298            text,
299        })
300        .collect();
301    edits.sort_by_key(|edit| edit.span);
302    saved_on(source, version, &edits).map(Some)
303}
304
305fn unmatched(offer: &Offer) -> AxError {
306    AxError::failure(
307        AxCode::InvalidArgs,
308        "decide proposals",
309        format!(
310            "proposal {} does not match the stretch {}..{} of its version",
311            offer.id(),
312            offer.span.start(),
313            offer.span.end()
314        ),
315    )
316    .with_recovery("reject the card: the history recorded an original its version does not hold")
317}
318
319fn refused(place: u32, why: &str) -> AxError {
320    AxError::failure(
321        AxCode::InvalidArgs,
322        "decide a proposal",
323        format!("sentence {place} {why}"),
324    )
325    .with_recovery("read the card again and give a verdict only to a changed sentence on it")
326}
327
328#[cfg(test)]
329#[allow(
330    clippy::unwrap_used,
331    clippy::expect_used,
332    clippy::panic,
333    clippy::indexing_slicing,
334    clippy::string_slice,
335    reason = "test code"
336)]
337mod tests;