heddle_object_model/object/
action_struct.rs1use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use super::{ActionId, Attribution, ContentHash, Operation, SemanticChange, StateId};
8
9#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
11pub struct Action {
12 #[serde(skip)]
14 id: Option<ActionId>,
15
16 pub from_state: Option<StateId>,
18
19 pub to_state: StateId,
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<StateId>,
42 to_state: StateId,
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 {
84 let data = rmp_serde::to_vec_named(self).expect("action identity should serialize");
85 ActionId::from_hash(ContentHash::compute_typed("action", &data))
86 }
87
88 pub fn id(&mut self) -> ActionId {
90 if self.id.is_none() {
91 self.id = Some(self.compute_id());
92 }
93 self.id.expect("id was just computed above")
94 }
95}
96
97#[cfg(test)]
98mod tests {
99 use chrono::TimeZone;
100
101 use super::*;
102 use crate::object::{Agent, Principal};
103
104 fn sample_action() -> Action {
105 Action::new(
106 None,
107 StateId::from_bytes([1; 32]),
108 Operation::Snapshot,
109 "capture state",
110 Attribution::human(Principal::new("Alice", "alice@example.com")),
111 )
112 }
113
114 #[test]
115 fn compute_id_distinguishes_semantic_changes() {
116 let base = sample_action().with_timestamp(Utc.timestamp_opt(1_700_000_000, 0).unwrap());
117 let changed = base
118 .clone()
119 .with_semantic_changes(vec![SemanticChange::FileModified {
120 path: "src/lib.rs".into(),
121 classification: None,
122 importance: None,
123 confidence: None,
124 }]);
125
126 assert_ne!(base.compute_id(), changed.compute_id());
127 }
128
129 #[test]
130 fn compute_id_distinguishes_attribution_and_subsecond_timestamps() {
131 let base = sample_action().with_timestamp(Utc.timestamp_opt(1_700_000_000, 10).unwrap());
132 let agent_authored = Action::new(
133 None,
134 StateId::from_bytes([1; 32]),
135 Operation::Snapshot,
136 "capture state",
137 Attribution::with_agent(
138 Principal::new("Alice", "alice@example.com"),
139 Agent::new("openai", "gpt-5"),
140 ),
141 )
142 .with_timestamp(Utc.timestamp_opt(1_700_000_000, 10).unwrap());
143 let different_nanos =
144 sample_action().with_timestamp(Utc.timestamp_opt(1_700_000_000, 11).unwrap());
145
146 assert_ne!(base.compute_id(), agent_authored.compute_id());
147 assert_ne!(base.compute_id(), different_nanos.compute_id());
148 }
149
150 #[test]
151 fn mutators_invalidate_cached_action_id() {
152 let mut action =
153 sample_action().with_timestamp(Utc.timestamp_opt(1_700_000_000, 0).unwrap());
154 let original_id = action.id();
155
156 action.add_semantic_change(SemanticChange::DependencyAdded {
157 name: "serde".to_string(),
158 version: "1".to_string(),
159 });
160
161 assert_ne!(action.id(), original_id);
162
163 let mut updated = action.with_timestamp(Utc.timestamp_opt(1_700_000_000, 42).unwrap());
164 let updated_id = updated.id();
165
166 assert_ne!(updated_id, original_id);
167 assert_eq!(updated_id, updated.compute_id());
168 }
169}