use indexmap::IndexSet;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use crate::canonical;
pub type SigId = String;
pub type StageId = String;
pub type OpId = String;
pub type EffectSet = BTreeSet<String>;
pub type ModuleRef = String;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum StageTransition {
Create { sig_id: SigId, stage_id: StageId },
Replace { sig_id: SigId, from: StageId, to: StageId },
Remove { sig_id: SigId, last: StageId },
Rename { from: SigId, to: SigId, body_stage_id: StageId },
ImportOnly,
Merge {
entries: BTreeMap<SigId, Option<StageId>>,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum OperationKind {
AddFunction {
sig_id: SigId,
stage_id: StageId,
effects: EffectSet,
},
RemoveFunction {
sig_id: SigId,
last_stage_id: StageId,
},
ModifyBody {
sig_id: SigId,
from_stage_id: StageId,
to_stage_id: StageId,
},
RenameSymbol {
from: SigId,
to: SigId,
body_stage_id: StageId,
},
ChangeEffectSig {
sig_id: SigId,
from_stage_id: StageId,
to_stage_id: StageId,
from_effects: EffectSet,
to_effects: EffectSet,
},
AddImport {
in_file: String,
module: ModuleRef,
},
RemoveImport {
in_file: String,
module: ModuleRef,
},
AddType {
sig_id: SigId,
stage_id: StageId,
},
RemoveType {
sig_id: SigId,
last_stage_id: StageId,
},
ModifyType {
sig_id: SigId,
from_stage_id: StageId,
to_stage_id: StageId,
},
Merge {
resolved: usize,
},
}
impl OperationKind {
pub fn merge_target(&self) -> Option<(SigId, Option<StageId>)> {
use OperationKind::*;
match self {
AddFunction { sig_id, stage_id, .. }
| AddType { sig_id, stage_id }
=> Some((sig_id.clone(), Some(stage_id.clone()))),
ModifyBody { sig_id, to_stage_id, .. }
| ChangeEffectSig { sig_id, to_stage_id, .. }
| ModifyType { sig_id, to_stage_id, .. }
=> Some((sig_id.clone(), Some(to_stage_id.clone()))),
RemoveFunction { sig_id, .. }
| RemoveType { sig_id, .. }
=> Some((sig_id.clone(), None)),
RenameSymbol { to, body_stage_id, .. }
=> Some((to.clone(), Some(body_stage_id.clone()))),
AddImport { .. } | RemoveImport { .. } | Merge { .. } => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Operation {
#[serde(flatten)]
pub kind: OperationKind,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub parents: Vec<OpId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub intent_id: Option<crate::intent::IntentId>,
}
impl Operation {
pub fn new(kind: OperationKind, parents: impl IntoIterator<Item = OpId>) -> Self {
let mut parents: Vec<OpId> = parents.into_iter().collect();
parents.sort();
parents.dedup();
Self { kind, parents, intent_id: None }
}
pub fn with_intent(mut self, intent_id: impl Into<crate::intent::IntentId>) -> Self {
self.intent_id = Some(intent_id.into());
self
}
pub fn op_id(&self) -> OpId {
let canonical = CanonicalView {
kind: &self.kind,
parents: self.parents.iter().collect::<IndexSet<_>>().into_iter().collect::<BTreeSet<_>>(),
intent_id: self.intent_id.as_deref(),
};
canonical::hash(&canonical)
}
}
#[derive(Serialize)]
struct CanonicalView<'a> {
#[serde(flatten)]
kind: &'a OperationKind,
parents: BTreeSet<&'a OpId>,
#[serde(skip_serializing_if = "Option::is_none")]
intent_id: Option<&'a str>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OperationRecord {
pub op_id: OpId,
#[serde(flatten)]
pub op: Operation,
pub produces: StageTransition,
}
impl OperationRecord {
pub fn new(op: Operation, produces: StageTransition) -> Self {
let op_id = op.op_id();
Self { op_id, op, produces }
}
}
#[cfg(test)]
mod tests {
use super::*;
fn add_factorial() -> OperationKind {
OperationKind::AddFunction {
sig_id: "fac::Int->Int".into(),
stage_id: "abc123".into(),
effects: BTreeSet::new(),
}
}
#[test]
fn identical_operations_have_identical_op_ids() {
let a = Operation::new(add_factorial(), []);
let b = Operation::new(add_factorial(), []);
assert_eq!(a.op_id(), b.op_id());
}
#[test]
fn different_operations_have_different_op_ids() {
let a = Operation::new(add_factorial(), []);
let b = Operation::new(
OperationKind::AddFunction {
sig_id: "double::Int->Int".into(),
stage_id: "abc123".into(),
effects: BTreeSet::new(),
},
[],
);
assert_ne!(a.op_id(), b.op_id());
}
#[test]
fn parent_set_changes_op_id() {
let no_parent = Operation::new(add_factorial(), []);
let with_parent = Operation::new(add_factorial(), ["op-parent-1".into()]);
assert_ne!(no_parent.op_id(), with_parent.op_id());
}
#[test]
fn parent_order_does_not_affect_op_id() {
let a = Operation::new(add_factorial(), ["b".into(), "a".into(), "c".into()]);
let b = Operation::new(add_factorial(), ["c".into(), "a".into(), "b".into()]);
assert_eq!(a.op_id(), b.op_id());
assert_eq!(a.parents, vec!["a".to_string(), "b".to_string(), "c".to_string()]);
}
#[test]
fn duplicate_parents_are_deduped() {
let with_dups = Operation::new(
add_factorial(),
["a".into(), "a".into(), "b".into()],
);
let no_dups = Operation::new(
add_factorial(),
["a".into(), "b".into()],
);
assert_eq!(with_dups.op_id(), no_dups.op_id());
assert_eq!(with_dups.parents, vec!["a".to_string(), "b".to_string()]);
}
#[test]
fn rename_with_same_body_hashes_equal_across_runs() {
let kind = OperationKind::RenameSymbol {
from: "parse::Str->Int".into(),
to: "parse_int::Str->Int".into(),
body_stage_id: "abc123".into(),
};
let a = Operation::new(kind.clone(), ["op-parent".into()]);
let b = Operation::new(kind, ["op-parent".into()]);
assert_eq!(a.op_id(), b.op_id());
}
#[test]
fn rename_does_not_collide_with_delete_plus_add() {
let rename = Operation::new(
OperationKind::RenameSymbol {
from: "parse::Str->Int".into(),
to: "parse_int::Str->Int".into(),
body_stage_id: "abc123".into(),
},
["op-parent".into()],
);
let remove = Operation::new(
OperationKind::RemoveFunction {
sig_id: "parse::Str->Int".into(),
last_stage_id: "abc123".into(),
},
["op-parent".into()],
);
let add = Operation::new(
OperationKind::AddFunction {
sig_id: "parse_int::Str->Int".into(),
stage_id: "abc123".into(),
effects: BTreeSet::new(),
},
["op-parent".into()],
);
assert_ne!(rename.op_id(), remove.op_id());
assert_ne!(rename.op_id(), add.op_id());
}
#[test]
fn effect_set_order_does_not_affect_op_id() {
let a_effects: EffectSet = ["io".into(), "fs_write".into()].into_iter().collect();
let b_effects: EffectSet = ["fs_write".into(), "io".into()].into_iter().collect();
let a = Operation::new(
OperationKind::AddFunction {
sig_id: "x".into(), stage_id: "s".into(), effects: a_effects,
},
[],
);
let b = Operation::new(
OperationKind::AddFunction {
sig_id: "x".into(), stage_id: "s".into(), effects: b_effects,
},
[],
);
assert_eq!(a.op_id(), b.op_id());
}
#[test]
fn op_id_is_64_char_lowercase_hex() {
let id = Operation::new(add_factorial(), []).op_id();
assert_eq!(id.len(), 64);
assert!(id.chars().all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)));
}
#[test]
fn round_trip_through_serde_json() {
let op = Operation::new(
OperationKind::ChangeEffectSig {
sig_id: "f".into(),
from_stage_id: "old".into(),
to_stage_id: "new".into(),
from_effects: BTreeSet::new(),
to_effects: ["io".into()].into_iter().collect(),
},
["op-parent".into()],
);
let json = serde_json::to_string(&op).expect("serialize");
let back: Operation = serde_json::from_str(&json).expect("deserialize");
assert_eq!(op, back);
assert_eq!(op.op_id(), back.op_id());
}
#[test]
fn operation_record_carries_op_id() {
let op = Operation::new(add_factorial(), []);
let expected = op.op_id();
let rec = OperationRecord::new(
op,
StageTransition::Create {
sig_id: "fac::Int->Int".into(),
stage_id: "abc123".into(),
},
);
assert_eq!(rec.op_id, expected);
}
#[test]
fn intent_id_is_part_of_op_id_canonical_hash() {
let no_intent = Operation::new(add_factorial(), []);
let with_intent_a = Operation::new(add_factorial(), [])
.with_intent("intent-a");
let with_intent_b = Operation::new(add_factorial(), [])
.with_intent("intent-b");
let with_intent_a_again = Operation::new(add_factorial(), [])
.with_intent("intent-a");
assert_ne!(no_intent.op_id(), with_intent_a.op_id());
assert_ne!(with_intent_a.op_id(), with_intent_b.op_id());
assert_eq!(with_intent_a.op_id(), with_intent_a_again.op_id());
}
#[test]
fn op_without_intent_keeps_pre_intent_op_id() {
let mut op = Operation::new(add_factorial(), []);
let baseline = op.op_id();
op.intent_id = Some("transient".into());
let with_intent = op.op_id();
assert_ne!(baseline, with_intent);
op.intent_id = None;
let back = op.op_id();
assert_eq!(baseline, back);
}
#[test]
fn canonical_form_is_stable_for_a_known_input() {
let op = Operation::new(
OperationKind::AddFunction {
sig_id: "fac::Int->Int".into(),
stage_id: "abc123".into(),
effects: BTreeSet::new(),
},
[],
);
assert_eq!(
op.op_id(),
"f112990d31ef2a63f3e5ca5680637ed36a54bc7e8230510ae0c0e93fcb39d104"
);
}
#[test]
fn merge_kind_round_trips() {
let op = Operation::new(
OperationKind::Merge { resolved: 3 },
["op-a".into(), "op-b".into()],
);
let json = serde_json::to_string(&op).expect("ser");
let back: Operation = serde_json::from_str(&json).expect("de");
assert_eq!(op, back);
assert_eq!(op.op_id(), back.op_id());
}
#[test]
fn merge_stage_transition_round_trips() {
let mut entries = BTreeMap::new();
entries.insert("sig-a".to_string(), Some("stage-a".to_string()));
entries.insert("sig-b".to_string(), None); let t = StageTransition::Merge { entries };
let json = serde_json::to_string(&t).expect("ser");
let back: StageTransition = serde_json::from_str(&json).expect("de");
assert_eq!(t, back);
}
#[test]
fn merge_resolved_count_changes_op_id() {
let parents: Vec<OpId> = vec!["op-a".into(), "op-b".into()];
let one = Operation::new(OperationKind::Merge { resolved: 1 }, parents.clone());
let two = Operation::new(OperationKind::Merge { resolved: 2 }, parents);
assert_ne!(one.op_id(), two.op_id());
}
#[test]
fn existing_add_function_op_id_is_unchanged_after_merge_added() {
let _merge = Operation::new(
OperationKind::Merge { resolved: 0 },
["op-x".into(), "op-y".into()],
);
let op = Operation::new(add_factorial(), []);
assert_eq!(
op.op_id(),
"f112990d31ef2a63f3e5ca5680637ed36a54bc7e8230510ae0c0e93fcb39d104"
);
}
}