ta_changeset/
changeset.rs1use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13use uuid::Uuid;
14
15use crate::diff::DiffContent;
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19#[serde(rename_all = "snake_case")]
20pub enum ChangeKind {
21 FsPatch,
23 DbPatch,
25 EmailDraft,
27 SocialDraft,
29 Other(String),
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35#[serde(rename_all = "snake_case")]
36pub enum CommitIntent {
37 None,
39 RequestCommit,
41 RequestSend,
43 RequestPost,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct ChangeSet {
53 pub changeset_id: Uuid,
55
56 pub target_uri: String,
58
59 pub kind: ChangeKind,
61
62 pub diff_content: DiffContent,
64
65 pub preview_ref: Option<String>,
67
68 pub risk_flags: Vec<String>,
70
71 pub commit_intent: CommitIntent,
73
74 pub created_at: DateTime<Utc>,
76
77 pub content_hash: String,
79}
80
81impl ChangeSet {
82 pub fn new(target_uri: String, kind: ChangeKind, diff_content: DiffContent) -> Self {
87 let content_hash = compute_content_hash(&diff_content);
88 Self {
89 changeset_id: Uuid::new_v4(),
90 target_uri,
91 kind,
92 diff_content,
93 preview_ref: None,
94 risk_flags: Vec::new(),
95 commit_intent: CommitIntent::None,
96 created_at: Utc::now(),
97 content_hash,
98 }
99 }
100
101 pub fn with_commit_intent(mut self, intent: CommitIntent) -> Self {
103 self.commit_intent = intent;
104 self
105 }
106
107 pub fn with_risk_flag(mut self, flag: impl Into<String>) -> Self {
109 self.risk_flags.push(flag.into());
110 self
111 }
112
113 pub fn verify_hash(&self) -> bool {
115 let expected = compute_content_hash(&self.diff_content);
116 self.content_hash == expected
117 }
118}
119
120fn compute_content_hash(diff: &DiffContent) -> String {
122 let json = serde_json::to_string(diff).unwrap_or_default();
123 let mut hasher = Sha256::new();
124 hasher.update(json.as_bytes());
125 format!("{:x}", hasher.finalize())
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 #[test]
133 fn changeset_creation_computes_hash() {
134 let cs = ChangeSet::new(
135 "fs://workspace/test.txt".to_string(),
136 ChangeKind::FsPatch,
137 DiffContent::CreateFile {
138 content: "hello world".to_string(),
139 },
140 );
141 assert!(!cs.content_hash.is_empty());
142 assert_eq!(cs.content_hash.len(), 64); }
144
145 #[test]
146 fn changeset_hash_is_deterministic() {
147 let diff = DiffContent::CreateFile {
148 content: "hello".to_string(),
149 };
150 let cs1 = ChangeSet::new("uri".to_string(), ChangeKind::FsPatch, diff.clone());
151 let cs2 = ChangeSet::new("uri".to_string(), ChangeKind::FsPatch, diff);
152 assert_eq!(cs1.content_hash, cs2.content_hash);
153 }
154
155 #[test]
156 fn changeset_hash_verification() {
157 let cs = ChangeSet::new(
158 "fs://workspace/test.txt".to_string(),
159 ChangeKind::FsPatch,
160 DiffContent::CreateFile {
161 content: "hello".to_string(),
162 },
163 );
164 assert!(cs.verify_hash());
165 }
166
167 #[test]
168 fn changeset_serialization_round_trip() {
169 let cs = ChangeSet::new(
170 "fs://workspace/test.txt".to_string(),
171 ChangeKind::FsPatch,
172 DiffContent::CreateFile {
173 content: "hello".to_string(),
174 },
175 )
176 .with_commit_intent(CommitIntent::RequestCommit)
177 .with_risk_flag("large_change");
178
179 let json = serde_json::to_string(&cs).unwrap();
180 let restored: ChangeSet = serde_json::from_str(&json).unwrap();
181
182 assert_eq!(cs.changeset_id, restored.changeset_id);
183 assert_eq!(cs.target_uri, restored.target_uri);
184 assert_eq!(cs.content_hash, restored.content_hash);
185 assert_eq!(cs.risk_flags, restored.risk_flags);
186 assert_eq!(cs.commit_intent, restored.commit_intent);
187 }
188
189 #[test]
190 fn change_kind_serializes_as_snake_case() {
191 let json = serde_json::to_string(&ChangeKind::FsPatch).unwrap();
192 assert_eq!(json, "\"fs_patch\"");
193
194 let json = serde_json::to_string(&ChangeKind::EmailDraft).unwrap();
195 assert_eq!(json, "\"email_draft\"");
196 }
197}