use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Commit {
pub id: String,
pub parent: Option<String>,
pub timestamp: DateTime<Utc>,
pub author: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
pub message: String,
pub manifest_hash: String,
#[serde(default)]
pub changes: Vec<Change>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Change {
pub path: String,
pub operation: ChangeOp,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub old_value: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub new_value: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ChangeOp {
Set,
Delete,
Append,
Remove,
}
impl Commit {
pub fn new(
parent: Option<String>,
author: &str,
message: &str,
manifest_hash: &str,
changes: Vec<Change>,
) -> Self {
let mut commit = Self {
id: String::new(), parent,
timestamp: Utc::now(),
author: author.to_string(),
signature: None,
message: message.to_string(),
manifest_hash: manifest_hash.to_string(),
changes,
};
commit.id = commit.compute_id();
commit
}
fn compute_id(&self) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(self.parent.as_deref().unwrap_or("root").as_bytes());
hasher.update(self.timestamp.to_rfc3339().as_bytes());
hasher.update(self.author.as_bytes());
hasher.update(self.message.as_bytes());
hasher.update(self.manifest_hash.as_bytes());
for change in &self.changes {
hasher.update(change.path.as_bytes());
hasher.update(format!("{:?}", change.operation).as_bytes());
}
let digest = hasher.finalize();
hex::encode(digest)
}
pub fn verify_id(&self) -> bool {
self.id == self.compute_id()
}
}
impl Change {
pub fn set(path: &str, old_value: Option<String>, new_value: String) -> Self {
Self {
path: path.to_string(),
operation: ChangeOp::Set,
old_value,
new_value: Some(new_value),
}
}
pub fn delete(path: &str, old_value: String) -> Self {
Self {
path: path.to_string(),
operation: ChangeOp::Delete,
old_value: Some(old_value),
new_value: None,
}
}
pub fn append(path: &str, new_value: String) -> Self {
Self {
path: path.to_string(),
operation: ChangeOp::Append,
old_value: None,
new_value: Some(new_value),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_commit_id_determinism() {
let commit = Commit::new(
None,
"test-author",
"initial commit",
"sha256:abc123",
vec![Change::set("properties.name", None, "Luke".to_string())],
);
assert!(commit.verify_id());
}
#[test]
fn test_commit_with_parent() {
let parent_commit = Commit::new(
None,
"test-author",
"initial commit",
"sha256:abc123",
vec![],
);
let child_commit = Commit::new(
Some(parent_commit.id.clone()),
"test-author",
"update homeworld",
"sha256:def456",
vec![Change::set(
"properties.homeworld",
None,
"nap://starwars/location/tatooine".to_string(),
)],
);
assert_eq!(child_commit.parent, Some(parent_commit.id));
assert!(child_commit.verify_id());
}
}