1use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct Commit {
20 pub id: String,
22
23 pub parent: Option<String>,
25
26 pub timestamp: DateTime<Utc>,
28
29 pub author: String,
31
32 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub signature: Option<String>,
35
36 pub message: String,
38
39 pub manifest_hash: String,
41
42 #[serde(default)]
44 pub changes: Vec<Change>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct Change {
50 pub path: String,
53
54 pub operation: ChangeOp,
56
57 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub old_value: Option<String>,
60
61 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub new_value: Option<String>,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "lowercase")]
69pub enum ChangeOp {
70 Set,
72 Delete,
74 Append,
76 Remove,
78}
79
80impl Commit {
81 pub fn new(
84 parent: Option<String>,
85 author: &str,
86 message: &str,
87 manifest_hash: &str,
88 changes: Vec<Change>,
89 ) -> Self {
90 let mut commit = Self {
91 id: String::new(), parent,
93 timestamp: Utc::now(),
94 author: author.to_string(),
95 signature: None,
96 message: message.to_string(),
97 manifest_hash: manifest_hash.to_string(),
98 changes,
99 };
100 commit.id = commit.compute_id();
101 commit
102 }
103
104 fn compute_id(&self) -> String {
107 let mut hasher = blake3::Hasher::new();
108 hasher.update(self.parent.as_deref().unwrap_or("root").as_bytes());
109 hasher.update(self.timestamp.to_rfc3339().as_bytes());
110 hasher.update(self.author.as_bytes());
111 hasher.update(self.message.as_bytes());
112 hasher.update(self.manifest_hash.as_bytes());
113
114 for change in &self.changes {
116 hasher.update(change.path.as_bytes());
117 hasher.update(format!("{:?}", change.operation).as_bytes());
118 }
119
120 let digest = hasher.finalize();
121 hex::encode(digest.as_bytes())
122 }
123
124 pub fn verify_id(&self) -> bool {
126 self.id == self.compute_id()
127 }
128}
129
130impl Change {
131 pub fn set(path: &str, old_value: Option<String>, new_value: String) -> Self {
133 Self {
134 path: path.to_string(),
135 operation: ChangeOp::Set,
136 old_value,
137 new_value: Some(new_value),
138 }
139 }
140
141 pub fn delete(path: &str, old_value: String) -> Self {
143 Self {
144 path: path.to_string(),
145 operation: ChangeOp::Delete,
146 old_value: Some(old_value),
147 new_value: None,
148 }
149 }
150
151 pub fn append(path: &str, new_value: String) -> Self {
153 Self {
154 path: path.to_string(),
155 operation: ChangeOp::Append,
156 old_value: None,
157 new_value: Some(new_value),
158 }
159 }
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 #[test]
167 fn test_commit_id_determinism() {
168 let commit = Commit::new(
172 None,
173 "test-author",
174 "initial commit",
175 "blake3:0000000000000000000000000000000000000000000000000000000000000000",
176 vec![Change::set("properties.name", None, "Luke".to_string())],
177 );
178 assert!(commit.verify_id());
179 }
180
181 #[test]
182 fn test_commit_with_parent() {
183 let parent_commit = Commit::new(
184 None,
185 "test-author",
186 "initial commit",
187 "blake3:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
188 vec![],
189 );
190 let child_commit = Commit::new(
191 Some(parent_commit.id.clone()),
192 "test-author",
193 "update homeworld",
194 "blake3:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
195 vec![Change::set(
196 "properties.homeworld",
197 None,
198 "nap://starwars/location/tatooine".to_string(),
199 )],
200 );
201 assert_eq!(child_commit.parent, Some(parent_commit.id));
202 assert!(child_commit.verify_id());
203 }
204}