use std::collections::HashSet;
use chappi_syntax::{Atom, Diagnostic, Document, Form, FormKind, Span};
use crate::diagnostic::error;
use crate::focus::decode_focus;
use crate::model::{decode_node, decode_relation, integer, is_node_head, list_head, symbol};
use crate::{
CognitiveState, FocusFrame, FocusStream, Node, NodeKind, Relation, state_hash, validate,
validate_patch,
};
type PatchDecodeResult<T> = Result<T, Box<Diagnostic>>;
#[derive(Clone, Debug)]
pub struct Patch {
pub version: u32,
pub base_hash: [u8; 32],
pub ops: Vec<PatchOp>,
pub span: Span,
}
#[derive(Clone, Debug)]
pub enum PatchOp {
Add(AddOp),
Revise(ReviseOp),
Retire(RetireOp),
AppendFocus(AppendFocusOp),
}
#[derive(Clone, Debug)]
pub struct AddOp {
pub value: AddValue,
pub span: Span,
}
#[derive(Clone, Debug)]
pub enum AddValue {
Node(Node),
Relation(Relation),
}
#[derive(Clone, Debug)]
pub struct ReviseOp {
pub expected_revision: u64,
pub node: Node,
pub span: Span,
}
#[derive(Clone, Debug)]
pub struct RetireOp {
pub node_id: String,
pub expected_revision: u64,
pub reason: Form,
pub span: Span,
}
#[derive(Clone, Debug)]
pub struct AppendFocusOp {
pub frame: FocusFrame,
pub span: Span,
}
impl TryFrom<Document> for Patch {
type Error = Vec<Diagnostic>;
fn try_from(document: Document) -> Result<Self, Self::Error> {
let mut header_seen = false;
let mut version = None;
let mut base_hash = None;
let mut header_span = empty_span();
let mut ops = Vec::new();
let mut diagnostics = Vec::new();
for form in document.forms {
let Some((head, items)) = list_head(&form) else {
diagnostics.push(error(
"invalid-patch-top-level-form",
form.span,
Vec::new(),
"patch top-level forms must be non-empty lists",
None,
));
continue;
};
if head == "chappi-patch" {
if header_seen {
diagnostics.push(error(
"duplicate-patch-header",
form.span,
vec!["header".into()],
"the patch header must appear exactly once",
None,
));
continue;
}
header_seen = true;
header_span = form.span;
if items.len() != 3 || integer(&items[1]) != Some("1") {
diagnostics.push(error(
"invalid-patch-header",
form.span,
vec!["header".into()],
"the patch header must be `(chappi-patch 1 \"<64 lowercase hex SHA-256>\")`",
None,
));
continue;
}
version = Some(1);
match decode_hash(&items[2]) {
Some(hash) => base_hash = Some(hash),
None => diagnostics.push(error(
"invalid-patch-base-hash",
items[2].span,
vec!["header".into(), "base_hash".into()],
"patch base hash must be a string containing exactly 64 lowercase hexadecimal characters",
None,
)),
}
continue;
}
let result = match head {
"+" => decode_add(items, form.span).map(PatchOp::Add),
"~" => decode_revise(items, form.span).map(PatchOp::Revise),
"-" => decode_retire(items, form.span).map(PatchOp::Retire),
">" => decode_append_focus(items, form.span).map(PatchOp::AppendFocus),
_ => Err(error(
"unknown-patch-operation",
form.span,
vec!["ops".into()],
format!("unknown patch operation `{head}`"),
None,
)
.into()),
};
match result {
Ok(op) => ops.push(op),
Err(diagnostic) => diagnostics.push(*diagnostic),
}
}
if !header_seen {
diagnostics.push(error(
"missing-patch-header",
empty_span(),
vec!["header".into()],
"the document must contain exactly one patch header",
None,
));
}
if diagnostics.is_empty() {
Ok(Self {
version: version.expect("valid patch has version"),
base_hash: base_hash.expect("valid patch has base hash"),
ops,
span: header_span,
})
} else {
Err(diagnostics)
}
}
}
fn decode_add(items: &[Form], span: Span) -> PatchDecodeResult<AddOp> {
if items.len() != 2 {
return Err(op_arity(
"invalid-add-arity",
span,
"add must be `(+ <node-or-rel-form>)`",
));
}
let Some((head, nested)) = list_head(&items[1]) else {
return Err(invalid_payload(
"invalid-add-payload",
items[1].span,
"add payload must be a node or relation form",
));
};
let value = if is_node_head(head) {
AddValue::Node(decode_node(head, nested, items[1].span)?)
} else if head == "rel" {
AddValue::Relation(decode_relation(nested, items[1].span)?)
} else {
return Err(invalid_payload(
"invalid-add-payload",
items[1].span,
"add payload must be a node or relation form",
));
};
Ok(AddOp { value, span })
}
fn decode_revise(items: &[Form], span: Span) -> PatchDecodeResult<ReviseOp> {
if items.len() != 3 {
return Err(op_arity(
"invalid-revise-arity",
span,
"revise must be `(~ <expected-revision> <node-form>)`",
));
}
let expected_revision = operation_revision(&items[1], "revise")?;
let Some((head, nested)) = list_head(&items[2]) else {
return Err(invalid_payload(
"invalid-revise-payload",
items[2].span,
"revise payload must be a node form",
));
};
if !is_node_head(head) {
return Err(invalid_payload(
"invalid-revise-payload",
items[2].span,
"revise payload must be a node form",
));
}
let node = decode_node(head, nested, items[2].span)?;
Ok(ReviseOp {
expected_revision,
node,
span,
})
}
fn decode_retire(items: &[Form], span: Span) -> PatchDecodeResult<RetireOp> {
if items.len() != 4 {
return Err(op_arity(
"invalid-retire-arity",
span,
"retire must be `(- <node-id> <expected-revision> <reason-form>)`",
));
}
let node_id = symbol(&items[1]).map(ToOwned::to_owned).ok_or_else(|| {
invalid_payload(
"invalid-retire-node-id",
items[1].span,
"retire node ID must be a symbol",
)
})?;
let expected_revision = operation_revision(&items[2], "retire")?;
Ok(RetireOp {
node_id,
expected_revision,
reason: items[3].clone(),
span,
})
}
fn decode_append_focus(items: &[Form], span: Span) -> PatchDecodeResult<AppendFocusOp> {
if items.len() != 2 {
return Err(op_arity(
"invalid-append-focus-arity",
span,
"append focus must be `(> <focus-form>)`",
));
}
let Some((head, nested)) = list_head(&items[1]) else {
return Err(invalid_payload(
"invalid-append-focus-payload",
items[1].span,
"append focus payload must be a focus form",
));
};
if head != "focus" {
return Err(invalid_payload(
"invalid-append-focus-payload",
items[1].span,
"append focus payload must be a focus form",
));
}
Ok(AppendFocusOp {
frame: decode_focus(nested, items[1].span)?,
span,
})
}
fn operation_revision(form: &Form, operation: &str) -> PatchDecodeResult<u64> {
integer(form)
.and_then(|value| value.parse().ok())
.ok_or_else(|| {
error(
"invalid-expected-revision",
form.span,
vec!["ops".into(), operation.into(), "expected_revision".into()],
"expected revision must be a non-negative 64-bit integer",
None,
)
.into()
})
}
fn op_arity(code: &str, span: Span, message: &str) -> Box<Diagnostic> {
error(code, span, vec!["ops".into()], message, None).into()
}
fn invalid_payload(code: &str, span: Span, message: &str) -> Box<Diagnostic> {
error(code, span, vec!["ops".into()], message, None).into()
}
fn decode_hash(form: &Form) -> Option<[u8; 32]> {
let FormKind::Atom(Atom::String(value)) = &form.kind else {
return None;
};
if value.len() != 64
|| !value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return None;
}
let mut hash = [0; 32];
for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
hash[index] = (hex_digit(pair[0])? << 4) | hex_digit(pair[1])?;
}
Some(hash)
}
fn hex_digit(value: u8) -> Option<u8> {
match value {
b'0'..=b'9' => Some(value - b'0'),
b'a'..=b'f' => Some(value - b'a' + 10),
_ => None,
}
}
pub fn apply_patch(
state: &CognitiveState,
patch: &Patch,
) -> Result<CognitiveState, Vec<Diagnostic>> {
let patch_diagnostics = validate_patch(patch);
if !patch_diagnostics.is_empty() {
return Err(patch_diagnostics);
}
if patch.version != state.version {
return Err(vec![error(
"patch-version-mismatch",
patch.span,
vec!["header".into(), "version".into()],
format!(
"patch version {} does not match snapshot version {}",
patch.version, state.version
),
None,
)]);
}
let mut candidate = state.clone();
let mut known_node_ids = state
.nodes
.iter()
.map(|node| node.id.clone())
.collect::<HashSet<_>>();
let mut diagnostics = Vec::new();
if patch.base_hash != state_hash(state) {
diagnostics.push(error(
"stale-patch-base",
patch.span,
vec!["header".into(), "base_hash".into()],
"patch base hash does not match the input snapshot",
None,
));
}
for op in &patch.ops {
match op {
PatchOp::Add(op) => {
apply_add(&mut candidate, &mut known_node_ids, op, &mut diagnostics)
}
PatchOp::Revise(op) => apply_revise(&mut candidate, op, &mut diagnostics),
PatchOp::Retire(op) => apply_retire(&mut candidate, op, &mut diagnostics),
PatchOp::AppendFocus(op) => apply_focus(&mut candidate, op, &mut diagnostics),
}
}
diagnostics.extend(validate(&candidate));
if diagnostics.is_empty() {
Ok(candidate)
} else {
Err(diagnostics)
}
}
fn apply_add(
state: &mut CognitiveState,
known_node_ids: &mut HashSet<String>,
op: &AddOp,
diagnostics: &mut Vec<Diagnostic>,
) {
match &op.value {
AddValue::Node(node) => {
let mut valid = true;
if node.revision != 0 {
diagnostics.push(error(
"invalid-add-revision",
op.span,
vec!["nodes".into(), node.id.clone(), "revision".into()],
format!(
"new node `{}` must begin at revision 0, not {}",
node.id, node.revision
),
None,
));
valid = false;
}
if known_node_ids.contains(&node.id) {
diagnostics.push(error(
"duplicate-node-id",
op.span,
vec!["nodes".into(), node.id.clone(), "id".into()],
format!("node ID `{}` already exists", node.id),
None,
));
valid = false;
}
if valid {
known_node_ids.insert(node.id.clone());
state.nodes.push(node.clone());
}
}
AddValue::Relation(relation) => {
if state.relations.iter().any(|existing| {
existing.kind == relation.kind
&& existing.source == relation.source
&& existing.target == relation.target
}) {
diagnostics.push(error(
"duplicate-relation",
op.span,
vec![
"relations".into(),
relation.kind.as_str().into(),
relation.source.clone(),
relation.target.clone(),
],
"relation already exists",
None,
));
} else {
state.relations.push(relation.clone());
}
}
}
}
fn apply_revise(state: &mut CognitiveState, op: &ReviseOp, diagnostics: &mut Vec<Diagnostic>) {
let Some(index) = state.nodes.iter().position(|node| node.id == op.node.id) else {
diagnostics.push(error(
"unknown-node-id",
op.span,
vec!["nodes".into(), op.node.id.clone()],
format!(
"node `{}` cannot be revised because it does not exist",
op.node.id
),
None,
));
return;
};
let current_revision = state.nodes[index].revision;
if current_revision != op.expected_revision {
diagnostics.push(error(
"revision-conflict",
op.span,
vec!["nodes".into(), op.node.id.clone(), "revision".into()],
format!(
"node `{}` is at revision {current_revision}, not {}",
op.node.id, op.expected_revision
),
None,
));
return;
}
let Some(next_revision) = current_revision.checked_add(1) else {
diagnostics.push(error(
"revision-overflow",
op.span,
vec!["nodes".into(), op.node.id.clone(), "revision".into()],
format!(
"node `{}` cannot advance beyond revision {}",
op.node.id,
u64::MAX
),
None,
));
return;
};
let mut replacement = op.node.clone();
replacement.revision = next_revision;
state.nodes[index] = replacement;
}
fn apply_retire(state: &mut CognitiveState, op: &RetireOp, diagnostics: &mut Vec<Diagnostic>) {
let Some(index) = state.nodes.iter().position(|node| node.id == op.node_id) else {
diagnostics.push(error(
"unknown-node-id",
op.span,
vec!["nodes".into(), op.node_id.clone()],
format!(
"node `{}` cannot be retired because it does not exist",
op.node_id
),
None,
));
return;
};
if state.nodes[index].revision != op.expected_revision {
diagnostics.push(error(
"revision-conflict",
op.span,
vec!["nodes".into(), op.node_id.clone(), "revision".into()],
format!(
"node `{}` is at revision {}, not {}",
op.node_id, state.nodes[index].revision, op.expected_revision
),
None,
));
return;
}
let referenced = state.nodes.iter().any(|node| {
node.owner == op.node_id
|| match &node.kind {
NodeKind::Relationship { target, .. }
| NodeKind::Affect { target, .. }
| NodeKind::Perspective(crate::Perspective { target, .. }) => target == &op.node_id,
_ => false,
}
}) || state
.relations
.iter()
.any(|relation| relation.source == op.node_id || relation.target == op.node_id)
|| state
.focus_streams
.iter()
.flat_map(|stream| &stream.frames)
.any(|frame| {
frame.owner == op.node_id
|| frame.foreground.contains(&op.node_id)
|| frame.background.contains(&op.node_id)
|| frame.residue.contains(&op.node_id)
});
if referenced {
diagnostics.push(error(
"node-still-referenced",
op.span,
vec!["nodes".into(), op.node_id.clone()],
format!(
"node `{}` cannot be retired while it is still referenced",
op.node_id
),
Some(
"revise or remove every owner, target, relation, and focus reference first".into(),
),
));
return;
}
state.nodes.remove(index);
}
fn apply_focus(state: &mut CognitiveState, op: &AppendFocusOp, diagnostics: &mut Vec<Diagnostic>) {
let frame = &op.frame;
let valid_sequence = match state
.focus_streams
.iter()
.find(|stream| stream.id == frame.stream_id)
{
None => {
frame.sequence == 0
&& frame.transition == crate::TransitionKind::Evoked
&& frame.previous_sequence.is_none()
}
Some(stream) => stream.frames.last().is_some_and(|previous| {
previous.sequence.checked_add(1) == Some(frame.sequence)
&& frame.previous_sequence == Some(previous.sequence)
}),
};
if !valid_sequence {
diagnostics.push(error(
"invalid-focus-append",
op.span,
vec![
"focus".into(),
frame.stream_id.clone(),
frame.sequence.to_string(),
],
"focus frame is not the exact valid successor for its stream",
None,
));
return;
}
let mut test_state = state.clone();
append_focus_unchecked(&mut test_state, frame.clone());
let focus_diagnostics = validate(&test_state)
.into_iter()
.filter(|diagnostic| {
diagnostic
.semantic_path
.first()
.is_some_and(|part| part == "focus")
})
.collect::<Vec<_>>();
if focus_diagnostics.is_empty() {
append_focus_unchecked(state, frame.clone());
} else {
diagnostics.extend(focus_diagnostics);
}
}
fn append_focus_unchecked(state: &mut CognitiveState, frame: FocusFrame) {
if let Some(stream) = state
.focus_streams
.iter_mut()
.find(|stream| stream.id == frame.stream_id)
{
stream.frames.push(frame);
} else {
state.focus_streams.push(FocusStream {
id: frame.stream_id.clone(),
frames: vec![frame],
});
state
.focus_streams
.sort_by(|left, right| left.id.cmp(&right.id));
}
}
fn empty_span() -> Span {
Span {
start: 0,
end: 0,
start_line: 1,
start_column: 1,
end_line: 1,
end_column: 1,
}
}