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    /// SHA-256 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    /// SHA-256 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 (SHA-256) of this commit.
105    /// Hashes: parent + timestamp + author + message + manifest_hash + changes.
106    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        // Include change paths and ops for determinism
117        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    /// Re-compute and verify the commit ID matches the stored value.
127    pub fn verify_id(&self) -> bool {
128        self.id == self.compute_id()
129    }
130}
131
132impl Change {
133    /// Create a `Set` change.
134    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    /// Create a `Delete` change.
144    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    /// Create an `Append` change.
154    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        // Two commits with same content should get the same ID
171        // (except timestamp — so we can't test exact equality easily).
172        // Instead, verify that id matches recompute.
173        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}