use crate::state_model::{ConflictClassification, StateId, StateRevision};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
pub const STATE_CONFLICT_CONTRACT_ID: &str = "feltdb.state.conflict";
pub const STATE_CONFLICT_CONTRACT_VERSION: u32 = 2;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StateConflictInput {
pub base: String,
pub contract: String,
pub left: String,
pub right: String,
pub version: u32,
}
impl StateConflictInput {
pub fn new(base: String, left: String, right: String) -> Self {
Self {
base,
contract: STATE_CONFLICT_CONTRACT_ID.to_string(),
left,
right,
version: STATE_CONFLICT_CONTRACT_VERSION,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ContractErrorCode {
ContractInputInvalid,
ContractIdMismatch,
ContractVersionUnsupported,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ContractErrorBody {
pub code: ContractErrorCode,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StateConflictOutput {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub classification: Option<ConflictClassification>,
pub contract: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<ContractErrorBody>,
pub version: u32,
}
impl StateConflictOutput {
fn classified(classification: ConflictClassification) -> Self {
Self {
classification: Some(classification),
contract: STATE_CONFLICT_CONTRACT_ID.to_string(),
error: None,
version: STATE_CONFLICT_CONTRACT_VERSION,
}
}
fn failure(code: ContractErrorCode) -> Self {
Self {
classification: None,
contract: STATE_CONFLICT_CONTRACT_ID.to_string(),
error: Some(ContractErrorBody { code }),
version: STATE_CONFLICT_CONTRACT_VERSION,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContractRuntime {
Native,
Wasm,
}
impl ContractRuntime {
pub fn as_str(self) -> &'static str {
match self {
Self::Native => "native",
Self::Wasm => "wasm",
}
}
}
fn revision(content: &str) -> StateRevision {
StateRevision {
id: StateId::compute(content),
resource: String::new(),
content: content.to_string(),
content_id: StateId::compute(content),
parent_id: None,
sequence: 0,
authority: String::new(),
timestamp_ms: 0,
metadata: BTreeMap::new(),
}
}
pub fn classify_states(base: &str, left: &str, right: &str) -> ConflictClassification {
ConflictClassification::classify(&revision(base), &revision(left), &revision(right))
}
pub fn canonical_input_bytes(input: &StateConflictInput) -> Vec<u8> {
serde_json::to_vec(input).expect("contract input is always serializable")
}
pub fn canonical_output_bytes(output: &StateConflictOutput) -> Vec<u8> {
serde_json::to_vec(output).expect("contract output is always serializable")
}
pub fn input_digest(input: &StateConflictInput) -> String {
format!("sha256:{:x}", Sha256::digest(canonical_input_bytes(input)))
}
pub fn evaluate_canonical(input: &[u8]) -> Vec<u8> {
canonical_output_bytes(&evaluate_canonical_output(input))
}
pub fn evaluate_canonical_output(input: &[u8]) -> StateConflictOutput {
let Ok(parsed) = serde_json::from_slice::<StateConflictInput>(input) else {
return StateConflictOutput::failure(ContractErrorCode::ContractInputInvalid);
};
if parsed.contract != STATE_CONFLICT_CONTRACT_ID {
return StateConflictOutput::failure(ContractErrorCode::ContractIdMismatch);
}
if parsed.version != STATE_CONFLICT_CONTRACT_VERSION {
return StateConflictOutput::failure(ContractErrorCode::ContractVersionUnsupported);
}
StateConflictOutput::classified(classify_states(&parsed.base, &parsed.left, &parsed.right))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state_model::ConflictClass;
fn classify(base: &str, left: &str, right: &str) -> String {
String::from_utf8(canonical_output_bytes(&StateConflictOutput::classified(
classify_states(base, left, right),
)))
.unwrap()
}
fn overall(base: &str, left: &str, right: &str) -> ConflictClass {
classify_states(base, left, right).overall
}
#[test]
fn the_contract_reads_only_revision_content() {
let plain = revision(r#"{"x":1}"#);
let decorated = StateRevision {
id: StateId::compute("something else entirely"),
resource: "some:other:resource".into(),
content: r#"{"x":1}"#.to_string(),
content_id: StateId::compute("not even the content"),
parent_id: Some(StateId::compute("a parent")),
sequence: 41,
authority: "someone-else".into(),
timestamp_ms: 1_700_000_000_000,
metadata: [("k".to_string(), serde_json::json!("v"))]
.into_iter()
.collect(),
};
let base = revision(r#"{"x":0}"#);
assert_eq!(
serde_json::to_string(&ConflictClassification::classify(&base, &plain, &plain))
.unwrap(),
serde_json::to_string(&ConflictClassification::classify(
&base, &decorated, &decorated
))
.unwrap()
);
}
#[test]
fn identical_states_classify_as_independent_with_no_paths() {
assert_eq!(
classify(r#"{"x":1}"#, r#"{"x":1}"#, r#"{"x":1}"#),
r#"{"classification":{"path_conflicts":[],"overall":"Independent"},"contract":"feltdb.state.conflict","version":2}"#
);
}
#[test]
fn one_sided_changes_are_independent() {
assert_eq!(
overall(r#"{"x":1}"#, r#"{"x":2}"#, r#"{"x":1}"#),
ConflictClass::Independent
);
assert_eq!(
overall(r#"{"x":1}"#, r#"{"x":1}"#, r#"{"x":2}"#),
ConflictClass::Independent
);
}
#[test]
fn agreeing_changes_converge_and_disagreeing_ones_conflict() {
assert_eq!(
overall(r#"{"x":1}"#, r#"{"x":2}"#, r#"{"x":2}"#),
ConflictClass::Convergent
);
assert_eq!(
overall(r#"{"x":1}"#, r#"{"x":2}"#, r#"{"x":3}"#),
ConflictClass::Conflict
);
assert_eq!(overall(r#"{"x":1}"#, "{}", "{}"), ConflictClass::Convergent);
assert_eq!(
overall(r#"{"x":1}"#, "{}", r#"{"x":2}"#),
ConflictClass::Conflict
);
}
#[test]
fn an_ancestor_descendant_overlap_conflicts() {
let output = classify_states(
r#"{"o":{"a":1}}"#,
r#"{"o":"replaced"}"#,
r#"{"o":{"a":2}}"#,
);
assert_eq!(output.overall, ConflictClass::Conflict);
assert_eq!(output.path_conflicts.len(), 2);
assert!(output
.path_conflicts
.iter()
.all(|entry| entry.classification == ConflictClass::Conflict));
assert_eq!(
overall(r#"{"o":{"a":1}}"#, "{}", r#"{"o":{"a":2}}"#),
ConflictClass::Conflict
);
assert_eq!(
overall(
r#"{"o":{"a":1,"b":1}}"#,
r#"{"o":{"a":2,"b":1}}"#,
r#"{"o":{"a":1,"b":2}}"#
),
ConflictClass::Independent
);
assert_eq!(
overall(
r#"{"a":{"b":1,"bc":1}}"#,
r#"{"a":{"b":2,"bc":1}}"#,
r#"{"a":{"b":1,"bc":2}}"#
),
ConflictClass::Independent
);
}
#[test]
fn base_value_is_the_base_states_value_at_the_path() {
let output = classify_states(r#"{"x":1}"#, r#"{"x":1}"#, r#"{"x":2}"#);
let entry = &output.path_conflicts[0];
assert_eq!(entry.base_value, Some(serde_json::json!(1)));
assert_eq!(entry.left_value, None, "the left branch changed nothing");
assert_eq!(entry.right_value, Some(serde_json::json!(2)));
let added = classify_states("{}", r#"{"x":1}"#, "{}");
assert_eq!(added.path_conflicts[0].base_value, None);
let forward = classify_states(r#"{"x":1}"#, r#"{"x":1}"#, r#"{"x":2}"#);
let reversed = classify_states(r#"{"x":1}"#, r#"{"x":2}"#, r#"{"x":1}"#);
assert_eq!(
forward.path_conflicts[0].base_value,
reversed.path_conflicts[0].base_value
);
}
#[test]
fn unparseable_content_classifies_as_json_null_rather_than_failing() {
let output = classify_states("not json at all", r#"{"x":1}"#, r#"{"x":1}"#);
assert_eq!(output.overall, ConflictClass::Convergent);
assert_eq!(output.path_conflicts[0].path, vec![]);
let bytes =
canonical_input_bytes(&StateConflictInput::new("{".into(), "}".into(), "]".into()));
let parsed = evaluate_canonical_output(&bytes);
assert!(parsed.error.is_none());
assert!(parsed.classification.is_some());
}
#[test]
fn unevaluatable_inputs_are_decisions_rather_than_failures() {
for (bytes, code) in [
(&b"not json"[..], ContractErrorCode::ContractInputInvalid),
(
br#"{"base":"{}","contract":"other","left":"{}","right":"{}","version":2}"#,
ContractErrorCode::ContractIdMismatch,
),
(
br#"{"base":"{}","contract":"feltdb.state.conflict","left":"{}","right":"{}","version":9}"#,
ContractErrorCode::ContractVersionUnsupported,
),
] {
let output = evaluate_canonical_output(bytes);
assert_eq!(output.error.map(|error| error.code), Some(code));
assert!(output.classification.is_none());
}
}
#[test]
fn canonical_round_trip_is_stable() {
let input = StateConflictInput::new(
r#"{"x":1}"#.into(),
r#"{"x":2}"#.into(),
r#"{"x":3}"#.into(),
);
let once = canonical_input_bytes(&input);
let parsed: StateConflictInput = serde_json::from_slice(&once).unwrap();
assert_eq!(parsed, input);
assert_eq!(canonical_input_bytes(&parsed), once);
assert_eq!(input_digest(&parsed), input_digest(&input));
}
#[test]
fn the_adapter_is_the_existing_classifier() {
for (base, left, right) in [
(r#"{"x":1}"#, r#"{"x":2}"#, r#"{"x":3}"#),
(r#"{"a":1,"b":1}"#, r#"{"a":2,"b":1}"#, r#"{"a":1,"b":2}"#),
("[1,2]", "[9,2]", "[8,2]"),
] {
assert_eq!(
serde_json::to_string(&classify_states(base, left, right)).unwrap(),
serde_json::to_string(&ConflictClassification::classify(
&revision(base),
&revision(left),
&revision(right)
))
.unwrap()
);
}
}
}