Skip to main content

nap_core/
commit.rs

1//! NAP Commit — the history primitive.
2//!
3//! A commit records a point-in-time snapshot of a manifest, plus
4//! patch metadata describing what changed. This is Option C from the
5//! design requirements: **Snapshot + Patch Metadata**.
6//!
7//! - The VCS (Git) stores the full snapshot (tree).
8//! - The commit object stores change descriptions (patches) for efficient
9//!   audit/provenance without requiring full diff reconstruction.
10//!
11//! Commits are NOT stored inside the manifest. The manifest stores only
12//! `head` — a pointer to the latest commit hash.
13
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16
17/// A NAP commit — snapshot + patch metadata.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct Commit {
20    /// BLAKE3 of commit content (self-referential hash).
21    pub id: String,
22
23    /// Parent commit hash. `None` for the initial commit.
24    pub parent: Option<String>,
25
26    /// When this commit was created.
27    pub timestamp: DateTime<Utc>,
28
29    /// Author identifier (DID key, email, or key fingerprint).
30    pub author: String,
31
32    /// Ed25519 signature over the commit hash (optional for v0).
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub signature: Option<String>,
35
36    /// Human-readable commit message.
37    pub message: String,
38
39    /// BLAKE3 content hash of the resulting manifest after this commit.
40    pub manifest_hash: String,
41
42    /// What changed in this commit (patch metadata for audit).
43    #[serde(default)]
44    pub changes: Vec<Change>,
45}
46
47/// A single change within a commit.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct Change {
50    /// Dot-notation path to the changed field.
51    /// e.g., `"properties.homeworld"`, `"representations.reference_image.hash"`.
52    pub path: String,
53
54    /// The kind of change.
55    pub operation: ChangeOp,
56
57    /// Previous value hash (for verification).
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub old_value: Option<String>,
60
61    /// New value hash or literal (for small values).
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub new_value: Option<String>,
64}
65
66/// The type of change operation.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
68#[serde(rename_all = "lowercase")]
69pub enum ChangeOp {
70    /// Set or update a field.
71    Set,
72    /// Delete a field.
73    Delete,
74    /// Append to an array/list.
75    Append,
76    /// Remove from an array/list.
77    Remove,
78}
79
80impl Commit {
81    /// Create a new commit. The `id` is computed after construction
82    /// by hashing the serialized content.
83    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(), // Placeholder — computed below
92            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    /// Compute the content-addressed ID (BLAKE3) of this commit.
105    /// Hashes: parent + timestamp + author + message + manifest_hash + changes.
106    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        // Include change paths and ops for determinism
115        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    /// Re-compute and verify the commit ID matches the stored value.
125    pub fn verify_id(&self) -> bool {
126        self.id == self.compute_id()
127    }
128}
129
130impl Change {
131    /// Create a `Set` change.
132    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    /// Create a `Delete` change.
142    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    /// Create an `Append` change.
152    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        // Two commits with same content should get the same ID
169        // (except timestamp — so we can't test exact equality easily).
170        // Instead, verify that id matches recompute.
171        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}