Skip to main content

objects/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, ChangeId, ContentHash, Operation, SemanticChange};
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<ChangeId>,
18
19    /// Destination state.
20    pub to_state: ChangeId,
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<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    /// 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 content.
80    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    /// Get the action ID, computing it if necessary.
109    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}