//! 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>, /// The `policy` reference (spec §10.1, §14.5): ObjectRef of the current policy. pub policy: Option, /// `tags/`: immutable states (spec §10.1). pub tags: 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, /// The workspace whose command recorded it (oxygen#99); absent in /// operations written before it was kept. pub workspace: Option, /// For an undo that reverted an older operation in place: that /// operation, so a further undo goes on past it. pub undoes: Option, } 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(), ), ), ( 3, self.policy .map(|p| Value::Bytes(p.0.to_vec())) .unwrap_or(Value::Null), ), ( 4, Value::Array( self.tags .iter() .map(|(n, st)| { Value::Array(vec![Value::Text(n.clone()), Value::Bytes(st.0.to_vec())]) }) .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); } } if let Some(p) = m.get(&3) { view.policy = if p.is_null() { None } else { Some(oref(p, "policy")?) }; } if let Some(ts) = m.get(&4) { for t in array(ts, "tags")? { let pair = array(t, "tag")?; if pair.len() != 2 { return Err(ObjectError::Schema("tag: expected [name, state]".into())); } view.tags.insert( text(&pair[0], "tag name")?.to_owned(), oref(&pair[1], "tag state")?, ); } } 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 { let mut fields = vec![ (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()), ]; // Absent when unknown, so older operations keep their encoding. if let Some(w) = &self.workspace { fields.push((5, Value::Bytes(w.0.to_vec()))); } if let Some(u) = &self.undoes { fields.push((6, Value::Bytes(u.0.to_vec()))); } Value::map(fields) } 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")?)?, workspace: match m.get(&5) { Some(v) => Some( WorkspaceId::from_slice(bytes(v, "workspace")?).ok_or_else(|| { ObjectError::Schema("workspace: expected 16 bytes".into()) })?, ), None => None, }, undoes: m.get(&6).map(|v| oref(v, "undoes")).transpose()?, }) } /// 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])); view.policy = Some(ObjectRef([6; 32])); view.tags.insert("v1".into(), ObjectRef([8; 32])); let op = Op { parents: vec![ObjectRef([7; 32])], hlc: Hlc { wall_ms: 5, logical: 1, }, command: "oxy commit".into(), view, workspace: None, undoes: None, }; let d = Op::decode(&op.encode()).unwrap(); assert_eq!(d, op); // The workspace and an undo's target travel when known. let mut known = op.clone(); known.workspace = Some(WorkspaceId([3; 16])); known.undoes = Some(ObjectRef([1; 32])); assert_eq!(Op::decode(&known.encode()).unwrap(), known); assert_ne!(known.hash(), op.hash()); 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])] ); } }