Skip to main content

ta_changeset/
changeset.rs

1// changeset.rs — The universal staged mutation type.
2//
3// A ChangeSet represents any pending change in the system — a file patch,
4// email draft, DB mutation, or social post. All changes are staged (collected)
5// by default and bundled into a PR package for review.
6//
7// The key insight: by representing ALL mutations uniformly, we can review
8// filesystem changes, email drafts, and DB writes in a single PR package.
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13use uuid::Uuid;
14
15use crate::diff::DiffContent;
16
17/// What kind of mutation this changeset represents.
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19#[serde(rename_all = "snake_case")]
20pub enum ChangeKind {
21    /// A filesystem file patch (create, modify, delete).
22    FsPatch,
23    /// A database mutation.
24    DbPatch,
25    /// An email draft.
26    EmailDraft,
27    /// A social media post draft.
28    SocialDraft,
29    /// Any other kind of mutation.
30    Other(String),
31}
32
33/// What the agent intends to do when the change is approved.
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35#[serde(rename_all = "snake_case")]
36pub enum CommitIntent {
37    /// No specific commit intent — just staging for review.
38    None,
39    /// Request to apply filesystem changes.
40    RequestCommit,
41    /// Request to send an email.
42    RequestSend,
43    /// Request to publish a social media post.
44    RequestPost,
45}
46
47/// A single staged mutation — the fundamental unit of the review system.
48///
49/// Every change an agent makes flows through this type, whether it's a
50/// file edit, email draft, or database write.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct ChangeSet {
53    /// Unique identifier for this changeset.
54    pub changeset_id: Uuid,
55
56    /// The resource being changed (e.g., "fs://workspace/src/main.rs").
57    pub target_uri: String,
58
59    /// What kind of mutation this is.
60    pub kind: ChangeKind,
61
62    /// The actual change content (diff, new file, etc.).
63    pub diff_content: DiffContent,
64
65    /// Optional pointer to a rendered preview (e.g., a diff viewer URL).
66    pub preview_ref: Option<String>,
67
68    /// Risk flags identified by the system (e.g., "contains_secrets", "large_change").
69    pub risk_flags: Vec<String>,
70
71    /// What the agent intends to do with this change once approved.
72    pub commit_intent: CommitIntent,
73
74    /// When this changeset was created.
75    pub created_at: DateTime<Utc>,
76
77    /// SHA-256 hash of the diff content for integrity verification.
78    pub content_hash: String,
79}
80
81impl ChangeSet {
82    /// Create a new changeset with automatically computed content hash.
83    ///
84    /// The content hash is computed from the serialized diff_content,
85    /// ensuring integrity can be verified later.
86    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    /// Set the commit intent and return self (builder pattern).
102    pub fn with_commit_intent(mut self, intent: CommitIntent) -> Self {
103        self.commit_intent = intent;
104        self
105    }
106
107    /// Add a risk flag and return self.
108    pub fn with_risk_flag(mut self, flag: impl Into<String>) -> Self {
109        self.risk_flags.push(flag.into());
110        self
111    }
112
113    /// Verify the content hash matches the actual diff content.
114    pub fn verify_hash(&self) -> bool {
115        let expected = compute_content_hash(&self.diff_content);
116        self.content_hash == expected
117    }
118}
119
120/// Compute SHA-256 hash of serialized diff content.
121fn 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); // SHA-256 hex length
143    }
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}