jj workspaces over the network
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(core): add core types - Change, ChangeRecord, Bookmark

+268
+13
crates/tandem-core/src/model.rs
··· 1 + //! Data model for Tandem 2 + 3 + use serde::{Deserialize, Serialize}; 4 + use uuid::Uuid; 5 + use chrono::{DateTime, Utc}; 6 + 7 + #[derive(Debug, Clone, Serialize, Deserialize)] 8 + pub struct Repository { 9 + pub id: Uuid, 10 + pub name: String, 11 + pub created_at: DateTime<Utc>, 12 + pub updated_at: DateTime<Utc>, 13 + }
+255
crates/tandem-core/src/types.rs
··· 1 + //! Core types for Tandem 2 + 3 + use chrono::{DateTime, Utc}; 4 + use serde::{Deserialize, Serialize}; 5 + use std::fmt; 6 + use std::str::FromStr; 7 + use uuid::Uuid; 8 + 9 + /// Stable identifier for a change, persists across rebases 10 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 11 + pub struct ChangeId(pub [u8; 32]); 12 + 13 + impl ChangeId { 14 + /// Create a new random ChangeId 15 + pub fn new() -> Self { 16 + let mut bytes = [0u8; 32]; 17 + rand::Rng::fill(&mut rand::thread_rng(), &mut bytes); 18 + ChangeId(bytes) 19 + } 20 + } 21 + 22 + impl Default for ChangeId { 23 + fn default() -> Self { 24 + Self::new() 25 + } 26 + } 27 + 28 + impl fmt::Display for ChangeId { 29 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 30 + write!(f, "{}", hex::encode(self.0)) 31 + } 32 + } 33 + 34 + impl FromStr for ChangeId { 35 + type Err = String; 36 + 37 + fn from_str(s: &str) -> Result<Self, Self::Err> { 38 + let bytes = hex::decode(s).map_err(|e| format!("Invalid hex: {}", e))?; 39 + if bytes.len() != 32 { 40 + return Err(format!("Expected 32 bytes, got {}", bytes.len())); 41 + } 42 + let mut arr = [0u8; 32]; 43 + arr.copy_from_slice(&bytes); 44 + Ok(ChangeId(arr)) 45 + } 46 + } 47 + 48 + /// Content-addressed tree hash 49 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 50 + pub struct TreeHash(pub [u8; 20]); 51 + 52 + impl TreeHash { 53 + /// Create TreeHash from hex string (for compatibility with object_store) 54 + pub fn new(hash: String) -> Self { 55 + assert_eq!(hash.len(), 40, "TreeHash must be 40 characters"); 56 + TreeHash::from_str(&hash).expect("Invalid hex string") 57 + } 58 + 59 + /// Get hex string representation (for compatibility with object_store) 60 + pub fn as_str(&self) -> String { 61 + self.to_string() 62 + } 63 + } 64 + 65 + impl fmt::Display for TreeHash { 66 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 67 + write!(f, "{}", hex::encode(self.0)) 68 + } 69 + } 70 + 71 + impl FromStr for TreeHash { 72 + type Err = String; 73 + 74 + fn from_str(s: &str) -> Result<Self, Self::Err> { 75 + let bytes = hex::decode(s).map_err(|e| format!("Invalid hex: {}", e))?; 76 + if bytes.len() != 20 { 77 + return Err(format!("Expected 20 bytes, got {}", bytes.len())); 78 + } 79 + let mut arr = [0u8; 20]; 80 + arr.copy_from_slice(&bytes); 81 + Ok(TreeHash(arr)) 82 + } 83 + } 84 + 85 + /// Content-addressed blob hash 86 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] 87 + pub struct BlobHash(pub [u8; 20]); 88 + 89 + impl BlobHash { 90 + /// Create BlobHash from hex string (for compatibility with object_store) 91 + pub fn new(hash: String) -> Self { 92 + assert_eq!(hash.len(), 40, "BlobHash must be 40 characters"); 93 + BlobHash::from_str(&hash).expect("Invalid hex string") 94 + } 95 + 96 + /// Get hex string representation (for compatibility with object_store) 97 + pub fn as_str(&self) -> String { 98 + self.to_string() 99 + } 100 + } 101 + 102 + impl fmt::Display for BlobHash { 103 + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 104 + write!(f, "{}", hex::encode(self.0)) 105 + } 106 + } 107 + 108 + impl FromStr for BlobHash { 109 + type Err = String; 110 + 111 + fn from_str(s: &str) -> Result<Self, Self::Err> { 112 + let bytes = hex::decode(s).map_err(|e| format!("Invalid hex: {}", e))?; 113 + if bytes.len() != 20 { 114 + return Err(format!("Expected 20 bytes, got {}", bytes.len())); 115 + } 116 + let mut arr = [0u8; 20]; 117 + arr.copy_from_slice(&bytes); 118 + Ok(BlobHash(arr)) 119 + } 120 + } 121 + 122 + /// User identity 123 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 124 + pub struct Identity { 125 + pub email: String, 126 + pub name: Option<String>, 127 + } 128 + 129 + /// The fundamental unit - identity persists across rebases 130 + #[derive(Debug, Clone, Serialize, Deserialize)] 131 + pub struct Change { 132 + pub id: ChangeId, 133 + pub tree: TreeHash, 134 + pub parents: Vec<ChangeId>, 135 + pub description: String, 136 + pub author: Identity, 137 + pub timestamp: DateTime<Utc>, 138 + } 139 + 140 + /// Record stored in Y.Doc - append-only with unique keys 141 + #[derive(Debug, Clone, Serialize, Deserialize)] 142 + pub struct ChangeRecord { 143 + pub record_id: Uuid, 144 + pub change_id: ChangeId, 145 + pub tree: TreeHash, 146 + pub parents: Vec<ChangeId>, 147 + pub description: String, 148 + pub author: Identity, 149 + pub timestamp: DateTime<Utc>, 150 + pub visible: bool, // false = abandoned/hidden 151 + } 152 + 153 + impl ChangeRecord { 154 + /// Create a ChangeRecord from a Change 155 + pub fn from_change(change: &Change) -> Self { 156 + ChangeRecord { 157 + record_id: Uuid::new_v4(), 158 + change_id: change.id, 159 + tree: change.tree, 160 + parents: change.parents.clone(), 161 + description: change.description.clone(), 162 + author: change.author.clone(), 163 + timestamp: change.timestamp, 164 + visible: true, 165 + } 166 + } 167 + } 168 + 169 + /// Rules for bookmark protection 170 + #[derive(Debug, Clone, Default, Serialize, Deserialize)] 171 + pub struct BookmarkRules { 172 + pub require_ci: bool, 173 + pub require_review: bool, 174 + } 175 + 176 + /// Named pointer to a change 177 + #[derive(Debug, Clone, Serialize, Deserialize)] 178 + pub struct Bookmark { 179 + pub name: String, 180 + pub target: ChangeId, 181 + pub protected: bool, 182 + pub rules: BookmarkRules, 183 + } 184 + 185 + /// Presence information for a user 186 + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 187 + pub struct PresenceInfo { 188 + pub user_id: String, 189 + pub change_id: ChangeId, 190 + pub device: String, 191 + pub timestamp: DateTime<Utc>, 192 + } 193 + 194 + #[cfg(test)] 195 + mod tests { 196 + use super::*; 197 + 198 + #[test] 199 + fn test_change_id_new() { 200 + let id1 = ChangeId::new(); 201 + let id2 = ChangeId::new(); 202 + assert_ne!(id1, id2); 203 + } 204 + 205 + #[test] 206 + fn test_change_id_display_parse() { 207 + let id = ChangeId::new(); 208 + let hex_str = id.to_string(); 209 + assert_eq!(hex_str.len(), 64); // 32 bytes * 2 chars per byte 210 + let parsed = ChangeId::from_str(&hex_str).unwrap(); 211 + assert_eq!(id, parsed); 212 + } 213 + 214 + #[test] 215 + fn test_tree_hash_display_parse() { 216 + let hash = TreeHash([1u8; 20]); 217 + let hex_str = hash.to_string(); 218 + assert_eq!(hex_str.len(), 40); // 20 bytes * 2 chars per byte 219 + let parsed = TreeHash::from_str(&hex_str).unwrap(); 220 + assert_eq!(hash, parsed); 221 + } 222 + 223 + #[test] 224 + fn test_blob_hash_display_parse() { 225 + let hash = BlobHash([2u8; 20]); 226 + let hex_str = hash.to_string(); 227 + assert_eq!(hex_str.len(), 40); // 20 bytes * 2 chars per byte 228 + let parsed = BlobHash::from_str(&hex_str).unwrap(); 229 + assert_eq!(hash, parsed); 230 + } 231 + 232 + #[test] 233 + fn test_change_record_from_change() { 234 + let change = Change { 235 + id: ChangeId::new(), 236 + tree: TreeHash([0u8; 20]), 237 + parents: vec![], 238 + description: "Test change".to_string(), 239 + author: Identity { 240 + email: "test@example.com".to_string(), 241 + name: Some("Test User".to_string()), 242 + }, 243 + timestamp: Utc::now(), 244 + }; 245 + 246 + let record = ChangeRecord::from_change(&change); 247 + assert_eq!(record.change_id, change.id); 248 + assert_eq!(record.tree, change.tree); 249 + assert_eq!(record.parents, change.parents); 250 + assert_eq!(record.description, change.description); 251 + assert_eq!(record.author, change.author); 252 + assert_eq!(record.timestamp, change.timestamp); 253 + assert_eq!(record.visible, true); 254 + } 255 + }