Skip to main content

heddle_object_model/object/
action_struct.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Action structure.
3
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6
7use super::{ActionId, Attribution, ContentHash, Operation, SemanticChange, StateId};
8
9/// An action records an operation between states.
10#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
11pub struct Action {
12    /// Unique identifier (derived from content).
13    #[serde(skip)]
14    id: Option<ActionId>,
15
16    /// Source state (None for initial state).
17    pub from_state: Option<StateId>,
18
19    /// Destination state.
20    pub to_state: StateId,
21
22    /// Type of operation.
23    pub operation: Operation,
24
25    /// Human-readable description.
26    pub description: String,
27
28    /// High-level semantic changes.
29    pub semantic_changes: Vec<SemanticChange>,
30
31    /// Who performed the action.
32    pub attribution: Attribution,
33
34    /// When the action was performed.
35    pub timestamp: DateTime<Utc>,
36}
37
38impl Action {
39    /// Create a new action.
40    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    /// Add semantic changes.
60    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    /// Add a single semantic change.
67    pub fn add_semantic_change(&mut self, change: SemanticChange) {
68        self.semantic_changes.push(change);
69        self.id = None;
70    }
71
72    /// Set the timestamp (for testing or importing).
73    pub fn with_timestamp(mut self, timestamp: DateTime<Utc>) -> Self {
74        self.timestamp = timestamp;
75        self.id = None;
76        self
77    }
78
79    /// Compute the action ID from the action's canonical MessagePack body.
80    ///
81    /// `id` is `#[serde(skip)]`, so the hash covers the stored action and not
82    /// a second JSON encoding of the same fields.
83    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    /// Get the action ID, computing it if necessary.
89    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}