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 use sha2::{Digest, Sha256};
108
109 let mut hasher = Sha256::new();
110 hasher.update(self.parent.as_deref().unwrap_or("root").as_bytes());
111 hasher.update(self.timestamp.to_rfc3339().as_bytes());
112 hasher.update(self.author.as_bytes());
113 hasher.update(self.message.as_bytes());
114 hasher.update(self.manifest_hash.as_bytes());
115
116 for change in &self.changes {
118 hasher.update(change.path.as_bytes());
119 hasher.update(format!("{:?}", change.operation).as_bytes());
120 }
121
122 let digest = hasher.finalize();
123 hex::encode(digest)
124 }
125
126 pub fn verify_id(&self) -> bool {
128 self.id == self.compute_id()
129 }
130}
131
132impl Change {
133 pub fn set(path: &str, old_value: Option<String>, new_value: String) -> Self {
135 Self {
136 path: path.to_string(),
137 operation: ChangeOp::Set,
138 old_value,
139 new_value: Some(new_value),
140 }
141 }
142
143 pub fn delete(path: &str, old_value: String) -> Self {
145 Self {
146 path: path.to_string(),
147 operation: ChangeOp::Delete,
148 old_value: Some(old_value),
149 new_value: None,
150 }
151 }
152
153 pub fn append(path: &str, new_value: String) -> Self {
155 Self {
156 path: path.to_string(),
157 operation: ChangeOp::Append,
158 old_value: None,
159 new_value: Some(new_value),
160 }
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn test_commit_id_determinism() {
170 let commit = Commit::new(
174 None,
175 "test-author",
176 "initial commit",
177 "sha256:abc123",
178 vec![Change::set("properties.name", None, "Luke".to_string())],
179 );
180 assert!(commit.verify_id());
181 }
182
183 #[test]
184 fn test_commit_with_parent() {
185 let parent_commit = Commit::new(
186 None,
187 "test-author",
188 "initial commit",
189 "sha256:abc123",
190 vec![],
191 );
192 let child_commit = Commit::new(
193 Some(parent_commit.id.clone()),
194 "test-author",
195 "update homeworld",
196 "sha256:def456",
197 vec![Change::set(
198 "properties.homeworld",
199 None,
200 "nap://starwars/location/tatooine".to_string(),
201 )],
202 );
203 assert_eq!(child_commit.parent, Some(parent_commit.id));
204 assert!(child_commit.verify_id());
205 }
206}