//! Operation log objects (spec §12.1): every mutation of local repository //! state records an `Op` whose `view` is the complete set of references and //! workspaces afterwards. //! //! Encoding: `bookmarks` is an array of `[name, state_ref]` sorted by name and //! `workspaces` an array of `[workspace_id, change_hash]` sorted by id, since //! canonical maps carry only integer keys. Repository format 1 encoded a //! bookmark as `[name, [heads]]`; `Op::decode_format1` reads that for migration. use crate::cbor::{self, Value}; use crate::hash::{hash_object, ChangeHash, ObjectRef, ObjectType}; use crate::ids::WorkspaceId; use crate::objects::{array, bytes, check_version, field, oref, text, Hlc, ObjectError, FORMAT_VERSION}; use std::collections::BTreeMap; #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct View { /// Bookmark name → ObjectRef of a `state` object (spec §10.1). pub bookmarks: BTreeMap, pub workspaces: BTreeMap, /// `remotes//bookmarks/`: remote → bookmark → state ObjectRef. pub remotes: BTreeMap>, } /// A format-1 view, only produced by `Op::decode_format1`. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Format1View { pub bookmarks: BTreeMap>, pub workspaces: BTreeMap, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct Op { pub parents: Vec, pub hlc: Hlc, pub command: String, pub view: View, } impl View { fn to_value(&self) -> Value { Value::map([ ( 0, Value::Array( self.bookmarks .iter() .map(|(n, st)| Value::Array(vec![Value::Text(n.clone()), Value::Bytes(st.0.to_vec())])) .collect(), ), ), ( 1, Value::Array( self.workspaces .iter() .map(|(w, h)| Value::Array(vec![Value::Bytes(w.0.to_vec()), Value::Bytes(h.0.to_vec())])) .collect(), ), ), ( 2, Value::Array( self.remotes .iter() .map(|(r, bms)| { Value::Array(vec![ Value::Text(r.clone()), Value::Array( bms.iter().map(|(n, st)| Value::Array(vec![Value::Text(n.clone()), Value::Bytes(st.0.to_vec())])).collect(), ), ]) }) .collect(), ), ), ]) } fn from_value(v: &Value) -> Result { let m = v.as_map().ok_or_else(|| ObjectError::Schema("view: expected map".into()))?; let mut view = View::default(); for b in array(field(m, 0, "bookmarks")?, "bookmarks")? { let pair = array(b, "bookmark")?; if pair.len() != 2 { return Err(ObjectError::Schema("bookmark: expected [name, heads]".into())); } let name = text(&pair[0], "bookmark name")?.to_owned(); view.bookmarks.insert(name, oref(&pair[1], "bookmark state")?); } view.workspaces = workspaces_from_value(field(m, 1, "workspaces")?)?; if let Some(rs) = m.get(&2) { for r in array(rs, "remotes")? { let pair = array(r, "remote")?; if pair.len() != 2 { return Err(ObjectError::Schema("remote: expected [name, bookmarks]".into())); } let name = text(&pair[0], "remote name")?.to_owned(); let mut bms = BTreeMap::new(); for b in array(&pair[1], "remote bookmarks")? { let bp = array(b, "remote bookmark")?; if bp.len() != 2 { return Err(ObjectError::Schema("remote bookmark: expected [name, state]".into())); } bms.insert(text(&bp[0], "bookmark name")?.to_owned(), oref(&bp[1], "bookmark state")?); } view.remotes.insert(name, bms); } } Ok(view) } } fn workspaces_from_value(v: &Value) -> Result, ObjectError> { let mut out = BTreeMap::new(); for w in array(v, "workspaces")? { let pair = array(w, "workspace")?; if pair.len() != 2 { return Err(ObjectError::Schema("workspace: expected [id, change]".into())); } let id = WorkspaceId::from_slice(bytes(&pair[0], "workspace id")?) .ok_or_else(|| ObjectError::Schema("workspace id: expected 16 bytes".into()))?; out.insert(id, oref(&pair[1], "workspace change")?); } Ok(out) } impl Format1View { fn from_value(v: &Value) -> Result { let m = v.as_map().ok_or_else(|| ObjectError::Schema("view: expected map".into()))?; let mut view = Format1View::default(); for b in array(field(m, 0, "bookmarks")?, "bookmarks")? { let pair = array(b, "bookmark")?; if pair.len() != 2 { return Err(ObjectError::Schema("bookmark: expected [name, heads]".into())); } let name = text(&pair[0], "bookmark name")?.to_owned(); let mut heads = array(&pair[1], "heads")?.iter().map(|h| oref(h, "head")).collect::, _>>()?; heads.sort(); heads.dedup(); view.bookmarks.insert(name, heads); } view.workspaces = workspaces_from_value(field(m, 1, "workspaces")?)?; Ok(view) } /// Format-1 encoding, kept for the migration test. #[cfg(test)] pub(crate) fn to_value(&self) -> Value { Value::map([ ( 0, Value::Array( self.bookmarks .iter() .map(|(n, hs)| { Value::Array(vec![ Value::Text(n.clone()), Value::Array(hs.iter().map(|h| Value::Bytes(h.0.to_vec())).collect()), ]) }) .collect(), ), ), ( 1, Value::Array( self.workspaces .iter() .map(|(w, h)| Value::Array(vec![Value::Bytes(w.0.to_vec()), Value::Bytes(h.0.to_vec())])) .collect(), ), ), ]) } } /// A format-1 operation as read for migration. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Format1Op { pub parents: Vec, pub hlc: Hlc, pub command: String, pub view: Format1View, } impl Format1Op { #[cfg(test)] pub(crate) fn encode(&self) -> Vec { cbor::encode(&Value::map([ (0, Value::Uint(FORMAT_VERSION)), (1, Value::Array(self.parents.iter().map(|p| Value::Bytes(p.0.to_vec())).collect())), (2, self.hlc.to_value()), (3, Value::Text(self.command.clone())), (4, self.view.to_value()), ])) } } /// Suffix on the `command` of operations recorded by an automatic /// working-copy snapshot rather than by a user command. pub const SNAPSHOT_SUFFIX: &str = " (snapshot)"; impl Op { /// Whether this operation only recorded a working-copy snapshot. pub fn is_snapshot(&self) -> bool { self.command.ends_with(SNAPSHOT_SUFFIX) } pub fn to_value(&self) -> Value { Value::map([ (0, Value::Uint(FORMAT_VERSION)), (1, Value::Array(self.parents.iter().map(|p| Value::Bytes(p.0.to_vec())).collect())), (2, self.hlc.to_value()), (3, Value::Text(self.command.clone())), (4, self.view.to_value()), ]) } pub fn encode(&self) -> Vec { cbor::encode(&self.to_value()) } pub fn hash(&self) -> ObjectRef { hash_object(ObjectType::Op, &self.encode()) } pub fn decode(b: &[u8]) -> Result { let v = cbor::decode(b)?; let m = v.as_map().ok_or_else(|| ObjectError::Schema("op: expected map".into()))?; check_version(m)?; Ok(Op { parents: array(field(m, 1, "parents")?, "parents")?.iter().map(|p| oref(p, "parent")).collect::, _>>()?, hlc: Hlc::from_value(field(m, 2, "hlc")?)?, command: text(field(m, 3, "command")?, "command")?.to_owned(), view: View::from_value(field(m, 4, "view")?)?, }) } /// Read an operation written by repository format 1. pub fn decode_format1(b: &[u8]) -> Result { let v = cbor::decode(b)?; let m = v.as_map().ok_or_else(|| ObjectError::Schema("op: expected map".into()))?; check_version(m)?; Ok(Format1Op { parents: array(field(m, 1, "parents")?, "parents")?.iter().map(|p| oref(p, "parent")).collect::, _>>()?, hlc: Hlc::from_value(field(m, 2, "hlc")?)?, command: text(field(m, 3, "command")?, "command")?.to_owned(), view: Format1View::from_value(field(m, 4, "view")?)?, }) } } #[cfg(test)] mod tests { use super::*; #[test] fn op_round_trip() { let mut view = View::default(); view.bookmarks.insert("main".into(), ObjectRef([2; 32])); view.bookmarks.insert("dev".into(), ObjectRef([3; 32])); view.workspaces.insert(WorkspaceId([5; 16]), ObjectRef([9; 32])); view.remotes.entry("origin".into()).or_default().insert("main".into(), ObjectRef([4; 32])); let op = Op { parents: vec![ObjectRef([7; 32])], hlc: Hlc { wall_ms: 5, logical: 1 }, command: "oxy commit".into(), view }; let d = Op::decode(&op.encode()).unwrap(); assert_eq!(d, op); assert_eq!(d.hash(), Op::decode(&d.encode()).unwrap().hash()); // A format-1 op does not decode as format 2, but does through the migration reader. let mut v1 = Format1View::default(); v1.bookmarks.insert("main".into(), vec![ObjectRef([2; 32]), ObjectRef([1; 32])]); v1.workspaces.insert(WorkspaceId([5; 16]), ObjectRef([9; 32])); let old = Format1Op { parents: vec![], hlc: Hlc::default(), command: "oxy init".into(), view: v1 }; assert!(Op::decode(&old.encode()).is_err()); let back = Op::decode_format1(&old.encode()).unwrap(); assert_eq!(back.view.bookmarks["main"], vec![ObjectRef([1; 32]), ObjectRef([2; 32])]); } }