1use std::collections::HashSet;
2
3use chappi_syntax::{Atom, Diagnostic, Document, Form, FormKind, Span};
4
5use crate::diagnostic::error;
6use crate::focus::decode_focus;
7use crate::model::{decode_node, decode_relation, integer, is_node_head, list_head, symbol};
8use crate::{
9 CognitiveState, FocusFrame, FocusStream, Node, NodeKind, Relation, state_hash, validate,
10 validate_patch,
11};
12
13type PatchDecodeResult<T> = Result<T, Box<Diagnostic>>;
14
15#[derive(Clone, Debug)]
16pub struct Patch {
17 pub version: u32,
18 pub base_hash: [u8; 32],
19 pub ops: Vec<PatchOp>,
20 pub span: Span,
21}
22
23#[derive(Clone, Debug)]
24pub enum PatchOp {
25 Add(AddOp),
26 Revise(ReviseOp),
27 Retire(RetireOp),
28 AppendFocus(AppendFocusOp),
29}
30
31#[derive(Clone, Debug)]
32pub struct AddOp {
33 pub value: AddValue,
34 pub span: Span,
35}
36
37#[derive(Clone, Debug)]
38pub enum AddValue {
39 Node(Node),
40 Relation(Relation),
41}
42
43#[derive(Clone, Debug)]
44pub struct ReviseOp {
45 pub expected_revision: u64,
46 pub node: Node,
47 pub span: Span,
48}
49
50#[derive(Clone, Debug)]
51pub struct RetireOp {
52 pub node_id: String,
53 pub expected_revision: u64,
54 pub reason: Form,
55 pub span: Span,
56}
57
58#[derive(Clone, Debug)]
59pub struct AppendFocusOp {
60 pub frame: FocusFrame,
61 pub span: Span,
62}
63
64impl TryFrom<Document> for Patch {
65 type Error = Vec<Diagnostic>;
66
67 fn try_from(document: Document) -> Result<Self, Self::Error> {
68 let mut header_seen = false;
69 let mut version = None;
70 let mut base_hash = None;
71 let mut header_span = empty_span();
72 let mut ops = Vec::new();
73 let mut diagnostics = Vec::new();
74
75 for form in document.forms {
76 let Some((head, items)) = list_head(&form) else {
77 diagnostics.push(error(
78 "invalid-patch-top-level-form",
79 form.span,
80 Vec::new(),
81 "patch top-level forms must be non-empty lists",
82 None,
83 ));
84 continue;
85 };
86
87 if head == "chappi-patch" {
88 if header_seen {
89 diagnostics.push(error(
90 "duplicate-patch-header",
91 form.span,
92 vec!["header".into()],
93 "the patch header must appear exactly once",
94 None,
95 ));
96 continue;
97 }
98 header_seen = true;
99 header_span = form.span;
100 if items.len() != 3 || integer(&items[1]) != Some("1") {
101 diagnostics.push(error(
102 "invalid-patch-header",
103 form.span,
104 vec!["header".into()],
105 "the patch header must be `(chappi-patch 1 \"<64 lowercase hex SHA-256>\")`",
106 None,
107 ));
108 continue;
109 }
110 version = Some(1);
111 match decode_hash(&items[2]) {
112 Some(hash) => base_hash = Some(hash),
113 None => diagnostics.push(error(
114 "invalid-patch-base-hash",
115 items[2].span,
116 vec!["header".into(), "base_hash".into()],
117 "patch base hash must be a string containing exactly 64 lowercase hexadecimal characters",
118 None,
119 )),
120 }
121 continue;
122 }
123
124 let result = match head {
125 "+" => decode_add(items, form.span).map(PatchOp::Add),
126 "~" => decode_revise(items, form.span).map(PatchOp::Revise),
127 "-" => decode_retire(items, form.span).map(PatchOp::Retire),
128 ">" => decode_append_focus(items, form.span).map(PatchOp::AppendFocus),
129 _ => Err(error(
130 "unknown-patch-operation",
131 form.span,
132 vec!["ops".into()],
133 format!("unknown patch operation `{head}`"),
134 None,
135 )
136 .into()),
137 };
138 match result {
139 Ok(op) => ops.push(op),
140 Err(diagnostic) => diagnostics.push(*diagnostic),
141 }
142 }
143
144 if !header_seen {
145 diagnostics.push(error(
146 "missing-patch-header",
147 empty_span(),
148 vec!["header".into()],
149 "the document must contain exactly one patch header",
150 None,
151 ));
152 }
153
154 if diagnostics.is_empty() {
155 Ok(Self {
156 version: version.expect("valid patch has version"),
157 base_hash: base_hash.expect("valid patch has base hash"),
158 ops,
159 span: header_span,
160 })
161 } else {
162 Err(diagnostics)
163 }
164 }
165}
166
167fn decode_add(items: &[Form], span: Span) -> PatchDecodeResult<AddOp> {
168 if items.len() != 2 {
169 return Err(op_arity(
170 "invalid-add-arity",
171 span,
172 "add must be `(+ <node-or-rel-form>)`",
173 ));
174 }
175 let Some((head, nested)) = list_head(&items[1]) else {
176 return Err(invalid_payload(
177 "invalid-add-payload",
178 items[1].span,
179 "add payload must be a node or relation form",
180 ));
181 };
182 let value = if is_node_head(head) {
183 AddValue::Node(decode_node(head, nested, items[1].span)?)
184 } else if head == "rel" {
185 AddValue::Relation(decode_relation(nested, items[1].span)?)
186 } else {
187 return Err(invalid_payload(
188 "invalid-add-payload",
189 items[1].span,
190 "add payload must be a node or relation form",
191 ));
192 };
193 Ok(AddOp { value, span })
194}
195
196fn decode_revise(items: &[Form], span: Span) -> PatchDecodeResult<ReviseOp> {
197 if items.len() != 3 {
198 return Err(op_arity(
199 "invalid-revise-arity",
200 span,
201 "revise must be `(~ <expected-revision> <node-form>)`",
202 ));
203 }
204 let expected_revision = operation_revision(&items[1], "revise")?;
205 let Some((head, nested)) = list_head(&items[2]) else {
206 return Err(invalid_payload(
207 "invalid-revise-payload",
208 items[2].span,
209 "revise payload must be a node form",
210 ));
211 };
212 if !is_node_head(head) {
213 return Err(invalid_payload(
214 "invalid-revise-payload",
215 items[2].span,
216 "revise payload must be a node form",
217 ));
218 }
219 let node = decode_node(head, nested, items[2].span)?;
220 Ok(ReviseOp {
221 expected_revision,
222 node,
223 span,
224 })
225}
226
227fn decode_retire(items: &[Form], span: Span) -> PatchDecodeResult<RetireOp> {
228 if items.len() != 4 {
229 return Err(op_arity(
230 "invalid-retire-arity",
231 span,
232 "retire must be `(- <node-id> <expected-revision> <reason-form>)`",
233 ));
234 }
235 let node_id = symbol(&items[1]).map(ToOwned::to_owned).ok_or_else(|| {
236 invalid_payload(
237 "invalid-retire-node-id",
238 items[1].span,
239 "retire node ID must be a symbol",
240 )
241 })?;
242 let expected_revision = operation_revision(&items[2], "retire")?;
243 Ok(RetireOp {
244 node_id,
245 expected_revision,
246 reason: items[3].clone(),
247 span,
248 })
249}
250
251fn decode_append_focus(items: &[Form], span: Span) -> PatchDecodeResult<AppendFocusOp> {
252 if items.len() != 2 {
253 return Err(op_arity(
254 "invalid-append-focus-arity",
255 span,
256 "append focus must be `(> <focus-form>)`",
257 ));
258 }
259 let Some((head, nested)) = list_head(&items[1]) else {
260 return Err(invalid_payload(
261 "invalid-append-focus-payload",
262 items[1].span,
263 "append focus payload must be a focus form",
264 ));
265 };
266 if head != "focus" {
267 return Err(invalid_payload(
268 "invalid-append-focus-payload",
269 items[1].span,
270 "append focus payload must be a focus form",
271 ));
272 }
273 Ok(AppendFocusOp {
274 frame: decode_focus(nested, items[1].span)?,
275 span,
276 })
277}
278
279fn operation_revision(form: &Form, operation: &str) -> PatchDecodeResult<u64> {
280 integer(form)
281 .and_then(|value| value.parse().ok())
282 .ok_or_else(|| {
283 error(
284 "invalid-expected-revision",
285 form.span,
286 vec!["ops".into(), operation.into(), "expected_revision".into()],
287 "expected revision must be a non-negative 64-bit integer",
288 None,
289 )
290 .into()
291 })
292}
293
294fn op_arity(code: &str, span: Span, message: &str) -> Box<Diagnostic> {
295 error(code, span, vec!["ops".into()], message, None).into()
296}
297
298fn invalid_payload(code: &str, span: Span, message: &str) -> Box<Diagnostic> {
299 error(code, span, vec!["ops".into()], message, None).into()
300}
301
302fn decode_hash(form: &Form) -> Option<[u8; 32]> {
303 let FormKind::Atom(Atom::String(value)) = &form.kind else {
304 return None;
305 };
306 if value.len() != 64
307 || !value
308 .bytes()
309 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
310 {
311 return None;
312 }
313 let mut hash = [0; 32];
314 for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
315 hash[index] = (hex_digit(pair[0])? << 4) | hex_digit(pair[1])?;
316 }
317 Some(hash)
318}
319
320fn hex_digit(value: u8) -> Option<u8> {
321 match value {
322 b'0'..=b'9' => Some(value - b'0'),
323 b'a'..=b'f' => Some(value - b'a' + 10),
324 _ => None,
325 }
326}
327
328pub fn apply_patch(
329 state: &CognitiveState,
330 patch: &Patch,
331) -> Result<CognitiveState, Vec<Diagnostic>> {
332 let patch_diagnostics = validate_patch(patch);
333 if !patch_diagnostics.is_empty() {
334 return Err(patch_diagnostics);
335 }
336
337 if patch.version != state.version {
338 return Err(vec![error(
339 "patch-version-mismatch",
340 patch.span,
341 vec!["header".into(), "version".into()],
342 format!(
343 "patch version {} does not match snapshot version {}",
344 patch.version, state.version
345 ),
346 None,
347 )]);
348 }
349
350 let mut candidate = state.clone();
351 let mut known_node_ids = state
352 .nodes
353 .iter()
354 .map(|node| node.id.clone())
355 .collect::<HashSet<_>>();
356 let mut diagnostics = Vec::new();
357
358 if patch.base_hash != state_hash(state) {
359 diagnostics.push(error(
360 "stale-patch-base",
361 patch.span,
362 vec!["header".into(), "base_hash".into()],
363 "patch base hash does not match the input snapshot",
364 None,
365 ));
366 }
367
368 for op in &patch.ops {
369 match op {
370 PatchOp::Add(op) => {
371 apply_add(&mut candidate, &mut known_node_ids, op, &mut diagnostics)
372 }
373 PatchOp::Revise(op) => apply_revise(&mut candidate, op, &mut diagnostics),
374 PatchOp::Retire(op) => apply_retire(&mut candidate, op, &mut diagnostics),
375 PatchOp::AppendFocus(op) => apply_focus(&mut candidate, op, &mut diagnostics),
376 }
377 }
378
379 diagnostics.extend(validate(&candidate));
380 if diagnostics.is_empty() {
381 Ok(candidate)
382 } else {
383 Err(diagnostics)
384 }
385}
386
387fn apply_add(
388 state: &mut CognitiveState,
389 known_node_ids: &mut HashSet<String>,
390 op: &AddOp,
391 diagnostics: &mut Vec<Diagnostic>,
392) {
393 match &op.value {
394 AddValue::Node(node) => {
395 let mut valid = true;
396 if node.revision != 0 {
397 diagnostics.push(error(
398 "invalid-add-revision",
399 op.span,
400 vec!["nodes".into(), node.id.clone(), "revision".into()],
401 format!(
402 "new node `{}` must begin at revision 0, not {}",
403 node.id, node.revision
404 ),
405 None,
406 ));
407 valid = false;
408 }
409 if known_node_ids.contains(&node.id) {
410 diagnostics.push(error(
411 "duplicate-node-id",
412 op.span,
413 vec!["nodes".into(), node.id.clone(), "id".into()],
414 format!("node ID `{}` already exists", node.id),
415 None,
416 ));
417 valid = false;
418 }
419 if valid {
420 known_node_ids.insert(node.id.clone());
421 state.nodes.push(node.clone());
422 }
423 }
424 AddValue::Relation(relation) => {
425 if state.relations.iter().any(|existing| {
426 existing.kind == relation.kind
427 && existing.source == relation.source
428 && existing.target == relation.target
429 }) {
430 diagnostics.push(error(
431 "duplicate-relation",
432 op.span,
433 vec![
434 "relations".into(),
435 relation.kind.as_str().into(),
436 relation.source.clone(),
437 relation.target.clone(),
438 ],
439 "relation already exists",
440 None,
441 ));
442 } else {
443 state.relations.push(relation.clone());
444 }
445 }
446 }
447}
448
449fn apply_revise(state: &mut CognitiveState, op: &ReviseOp, diagnostics: &mut Vec<Diagnostic>) {
450 let Some(index) = state.nodes.iter().position(|node| node.id == op.node.id) else {
451 diagnostics.push(error(
452 "unknown-node-id",
453 op.span,
454 vec!["nodes".into(), op.node.id.clone()],
455 format!(
456 "node `{}` cannot be revised because it does not exist",
457 op.node.id
458 ),
459 None,
460 ));
461 return;
462 };
463 let current_revision = state.nodes[index].revision;
464 if current_revision != op.expected_revision {
465 diagnostics.push(error(
466 "revision-conflict",
467 op.span,
468 vec!["nodes".into(), op.node.id.clone(), "revision".into()],
469 format!(
470 "node `{}` is at revision {current_revision}, not {}",
471 op.node.id, op.expected_revision
472 ),
473 None,
474 ));
475 return;
476 }
477 let Some(next_revision) = current_revision.checked_add(1) else {
478 diagnostics.push(error(
479 "revision-overflow",
480 op.span,
481 vec!["nodes".into(), op.node.id.clone(), "revision".into()],
482 format!(
483 "node `{}` cannot advance beyond revision {}",
484 op.node.id,
485 u64::MAX
486 ),
487 None,
488 ));
489 return;
490 };
491 let mut replacement = op.node.clone();
492 replacement.revision = next_revision;
493 state.nodes[index] = replacement;
494}
495
496fn apply_retire(state: &mut CognitiveState, op: &RetireOp, diagnostics: &mut Vec<Diagnostic>) {
497 let Some(index) = state.nodes.iter().position(|node| node.id == op.node_id) else {
498 diagnostics.push(error(
499 "unknown-node-id",
500 op.span,
501 vec!["nodes".into(), op.node_id.clone()],
502 format!(
503 "node `{}` cannot be retired because it does not exist",
504 op.node_id
505 ),
506 None,
507 ));
508 return;
509 };
510 if state.nodes[index].revision != op.expected_revision {
511 diagnostics.push(error(
512 "revision-conflict",
513 op.span,
514 vec!["nodes".into(), op.node_id.clone(), "revision".into()],
515 format!(
516 "node `{}` is at revision {}, not {}",
517 op.node_id, state.nodes[index].revision, op.expected_revision
518 ),
519 None,
520 ));
521 return;
522 }
523
524 let referenced = state.nodes.iter().any(|node| {
525 node.owner == op.node_id
526 || match &node.kind {
527 NodeKind::Relationship { target, .. }
528 | NodeKind::Affect { target, .. }
529 | NodeKind::Perspective(crate::Perspective { target, .. }) => target == &op.node_id,
530 _ => false,
531 }
532 }) || state
533 .relations
534 .iter()
535 .any(|relation| relation.source == op.node_id || relation.target == op.node_id)
536 || state
537 .focus_streams
538 .iter()
539 .flat_map(|stream| &stream.frames)
540 .any(|frame| {
541 frame.owner == op.node_id
542 || frame.foreground.contains(&op.node_id)
543 || frame.background.contains(&op.node_id)
544 || frame.residue.contains(&op.node_id)
545 });
546 if referenced {
547 diagnostics.push(error(
548 "node-still-referenced",
549 op.span,
550 vec!["nodes".into(), op.node_id.clone()],
551 format!(
552 "node `{}` cannot be retired while it is still referenced",
553 op.node_id
554 ),
555 Some(
556 "revise or remove every owner, target, relation, and focus reference first".into(),
557 ),
558 ));
559 return;
560 }
561 state.nodes.remove(index);
562}
563
564fn apply_focus(state: &mut CognitiveState, op: &AppendFocusOp, diagnostics: &mut Vec<Diagnostic>) {
565 let frame = &op.frame;
566 let valid_sequence = match state
567 .focus_streams
568 .iter()
569 .find(|stream| stream.id == frame.stream_id)
570 {
571 None => {
572 frame.sequence == 0
573 && frame.transition == crate::TransitionKind::Evoked
574 && frame.previous_sequence.is_none()
575 }
576 Some(stream) => stream.frames.last().is_some_and(|previous| {
577 previous.sequence.checked_add(1) == Some(frame.sequence)
578 && frame.previous_sequence == Some(previous.sequence)
579 }),
580 };
581 if !valid_sequence {
582 diagnostics.push(error(
583 "invalid-focus-append",
584 op.span,
585 vec![
586 "focus".into(),
587 frame.stream_id.clone(),
588 frame.sequence.to_string(),
589 ],
590 "focus frame is not the exact valid successor for its stream",
591 None,
592 ));
593 return;
594 }
595
596 let mut test_state = state.clone();
597 append_focus_unchecked(&mut test_state, frame.clone());
598 let focus_diagnostics = validate(&test_state)
599 .into_iter()
600 .filter(|diagnostic| {
601 diagnostic
602 .semantic_path
603 .first()
604 .is_some_and(|part| part == "focus")
605 })
606 .collect::<Vec<_>>();
607 if focus_diagnostics.is_empty() {
608 append_focus_unchecked(state, frame.clone());
609 } else {
610 diagnostics.extend(focus_diagnostics);
611 }
612}
613
614fn append_focus_unchecked(state: &mut CognitiveState, frame: FocusFrame) {
615 if let Some(stream) = state
616 .focus_streams
617 .iter_mut()
618 .find(|stream| stream.id == frame.stream_id)
619 {
620 stream.frames.push(frame);
621 } else {
622 state.focus_streams.push(FocusStream {
623 id: frame.stream_id.clone(),
624 frames: vec![frame],
625 });
626 state
627 .focus_streams
628 .sort_by(|left, right| left.id.cmp(&right.id));
629 }
630}
631
632fn empty_span() -> Span {
633 Span {
634 start: 0,
635 end: 0,
636 start_line: 1,
637 start_column: 1,
638 end_line: 1,
639 end_column: 1,
640 }
641}