1use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use super::{ActionId, Attribution, ChangeId, ContentHash, Operation, SemanticChange};
8
9#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
11pub struct Action {
12 #[serde(skip)]
14 id: Option<ActionId>,
15
16 pub from_state: Option<ChangeId>,
18
19 pub to_state: ChangeId,
21
22 pub operation: Operation,
24
25 pub description: String,
27
28 pub semantic_changes: Vec<SemanticChange>,
30
31 pub attribution: Attribution,
33
34 pub timestamp: DateTime<Utc>,
36}
37
38impl Action {
39 pub fn new(
41 from_state: Option<ChangeId>,
42 to_state: ChangeId,
43 operation: Operation,
44 description: impl Into<String>,
45 attribution: Attribution,
46 ) -> Self {
47 Self {
48 id: None,
49 from_state,
50 to_state,
51 operation,
52 description: description.into(),
53 semantic_changes: Vec::new(),
54 attribution,
55 timestamp: Utc::now(),
56 }
57 }
58
59 pub fn with_semantic_changes(mut self, changes: Vec<SemanticChange>) -> Self {
61 self.semantic_changes = changes;
62 self.id = None;
63 self
64 }
65
66 pub fn add_semantic_change(&mut self, change: SemanticChange) {
68 self.semantic_changes.push(change);
69 self.id = None;
70 }
71
72 pub fn with_timestamp(mut self, timestamp: DateTime<Utc>) -> Self {
74 self.timestamp = timestamp;
75 self.id = None;
76 self
77 }
78
79 pub fn compute_id(&self) -> ActionId {
81 #[derive(Serialize)]
82 struct ActionIdentity<'a> {
83 from_state: Option<&'a ChangeId>,
84 to_state: &'a ChangeId,
85 operation: &'a Operation,
86 description: &'a str,
87 semantic_changes: &'a [SemanticChange],
88 attribution: &'a Attribution,
89 timestamp_secs: i64,
90 timestamp_nanos: u32,
91 }
92
93 let identity = ActionIdentity {
94 from_state: self.from_state.as_ref(),
95 to_state: &self.to_state,
96 operation: &self.operation,
97 description: &self.description,
98 semantic_changes: &self.semantic_changes,
99 attribution: &self.attribution,
100 timestamp_secs: self.timestamp.timestamp(),
101 timestamp_nanos: self.timestamp.timestamp_subsec_nanos(),
102 };
103 let data = serde_json::to_vec(&identity).expect("action identity should serialize");
104
105 ActionId::from_hash(ContentHash::compute_typed("action", &data))
106 }
107
108 pub fn id(&mut self) -> ActionId {
110 if self.id.is_none() {
111 self.id = Some(self.compute_id());
112 }
113 self.id.expect("id was just computed above")
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use chrono::TimeZone;
120
121 use super::*;
122 use crate::object::{Agent, Principal};
123
124 fn sample_action() -> Action {
125 Action::new(
126 None,
127 ChangeId::from_bytes([1; 16]),
128 Operation::Snapshot,
129 "capture state",
130 Attribution::human(Principal::new("Alice", "alice@example.com")),
131 )
132 }
133
134 #[test]
135 fn compute_id_distinguishes_semantic_changes() {
136 let base = sample_action().with_timestamp(Utc.timestamp_opt(1_700_000_000, 0).unwrap());
137 let changed = base
138 .clone()
139 .with_semantic_changes(vec![SemanticChange::FileModified {
140 path: "src/lib.rs".into(),
141 classification: None,
142 importance: None,
143 confidence: None,
144 }]);
145
146 assert_ne!(base.compute_id(), changed.compute_id());
147 }
148
149 #[test]
150 fn compute_id_distinguishes_attribution_and_subsecond_timestamps() {
151 let base = sample_action().with_timestamp(Utc.timestamp_opt(1_700_000_000, 10).unwrap());
152 let agent_authored = Action::new(
153 None,
154 ChangeId::from_bytes([1; 16]),
155 Operation::Snapshot,
156 "capture state",
157 Attribution::with_agent(
158 Principal::new("Alice", "alice@example.com"),
159 Agent::new("openai", "gpt-5"),
160 ),
161 )
162 .with_timestamp(Utc.timestamp_opt(1_700_000_000, 10).unwrap());
163 let different_nanos =
164 sample_action().with_timestamp(Utc.timestamp_opt(1_700_000_000, 11).unwrap());
165
166 assert_ne!(base.compute_id(), agent_authored.compute_id());
167 assert_ne!(base.compute_id(), different_nanos.compute_id());
168 }
169
170 #[test]
171 fn mutators_invalidate_cached_action_id() {
172 let mut action =
173 sample_action().with_timestamp(Utc.timestamp_opt(1_700_000_000, 0).unwrap());
174 let original_id = action.id();
175
176 action.add_semantic_change(SemanticChange::DependencyAdded {
177 name: "serde".to_string(),
178 version: "1".to_string(),
179 });
180
181 assert_ne!(action.id(), original_id);
182
183 let mut updated = action.with_timestamp(Utc.timestamp_opt(1_700_000_000, 42).unwrap());
184 let updated_id = updated.id();
185
186 assert_ne!(updated_id, original_id);
187 assert_eq!(updated_id, updated.compute_id());
188 }
189}