1use 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
33pub const PROPOSAL_ID_TAG: &[u8] = b"sprawling proposal v1\0";
36
37#[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 Same,
44 Delete,
46 Insert,
48}
49
50#[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#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct Review {
72 slices: Vec<Slice>,
73}
74
75impl Review {
76 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 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#[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 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 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 pub fn review(&self) -> Review {
229 Review::of(&self.before, &self.after)
230 }
231}
232
233pub 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;