Skip to main content

chappi_core/
patch.rs

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}