Skip to main content

git_internal/internal/object/
intent_event.rs

1//! Intent lifecycle event.
2//!
3//! `IntentEvent` records append-only lifecycle facts for an `Intent`.
4//!
5//! # How to use this object
6//!
7//! - Append an event when an intent is analyzed, completed, or
8//!   cancelled.
9//! - Include `result_commit` only when the lifecycle transition produced
10//!   a repository commit.
11//! - Include `next_intent_id` on a completed event when completion
12//!   recommends that Libra continue with a follow-up `Intent`.
13//! - Keep the `Intent` snapshot immutable; lifecycle belongs here.
14//!
15//! # How it works with other objects
16//!
17//! - `IntentEvent.intent_id` attaches the event to an `Intent`.
18//! - `IntentEvent.next_intent_id` can point at a recommended follow-up
19//!   `Intent`, but it does not replace `Intent.parents` revision
20//!   history.
21//! - `Decision` and final repository actions may feed data such as
22//!   `result_commit`.
23//!
24//! # How Libra should call it
25//!
26//! Libra should derive the current intent lifecycle state from the most
27//! recent relevant `IntentEvent`, not by mutating the `Intent` object.
28
29use std::fmt;
30
31use serde::{Deserialize, Serialize};
32use uuid::Uuid;
33
34use crate::{
35    errors::GitError,
36    hash::ObjectHash,
37    internal::object::{
38        ObjectTrait,
39        integrity::IntegrityHash,
40        types::{ActorRef, Header, ObjectType},
41    },
42};
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45#[serde(rename_all = "snake_case")]
46pub enum IntentEventKind {
47    /// The intent has been analyzed into a structured interpretation.
48    Analyzed,
49    /// The intent finished successfully.
50    Completed,
51    /// The intent was cancelled before completion.
52    Cancelled,
53    /// A forward-compatible lifecycle label that this binary does not
54    /// recognize yet.
55    #[serde(untagged)]
56    Other(String),
57}
58
59/// Append-only lifecycle fact for one `Intent`.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61#[serde(deny_unknown_fields)]
62pub struct IntentEvent {
63    /// Common object header carrying the immutable object id, type,
64    /// creator, and timestamps.
65    #[serde(flatten)]
66    header: Header,
67    /// Canonical target intent for this lifecycle fact.
68    intent_id: Uuid,
69    /// Lifecycle transition kind being recorded.
70    kind: IntentEventKind,
71    /// Optional human-readable explanation for the transition.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    reason: Option<String>,
74    /// Optional resulting repository commit associated with the event.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    result_commit: Option<IntegrityHash>,
77    /// Optional recommended follow-up intent to work on next.
78    ///
79    /// This is a recommendation edge emitted when the current intent is
80    /// completed and the system wants to suggest the next request to
81    /// process. It does not express revision lineage; semantic revision
82    /// history still belongs in `Intent.parents`.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    next_intent_id: Option<Uuid>,
85}
86
87impl IntentEvent {
88    /// Create a new lifecycle event for the given intent.
89    pub fn new(
90        created_by: ActorRef,
91        intent_id: Uuid,
92        kind: IntentEventKind,
93    ) -> Result<Self, String> {
94        Ok(Self {
95            header: Header::new(ObjectType::IntentEvent, created_by)?,
96            intent_id,
97            kind,
98            reason: None,
99            result_commit: None,
100            next_intent_id: None,
101        })
102    }
103
104    /// Return the immutable header for this event.
105    pub fn header(&self) -> &Header {
106        &self.header
107    }
108
109    /// Return the canonical target intent id.
110    pub fn intent_id(&self) -> Uuid {
111        self.intent_id
112    }
113
114    /// Return the lifecycle transition kind.
115    pub fn kind(&self) -> &IntentEventKind {
116        &self.kind
117    }
118
119    /// Return the human-readable explanation, if present.
120    pub fn reason(&self) -> Option<&str> {
121        self.reason.as_deref()
122    }
123
124    /// Return the resulting repository commit, if present.
125    pub fn result_commit(&self) -> Option<&IntegrityHash> {
126        self.result_commit.as_ref()
127    }
128
129    /// Return the recommended follow-up intent id, if present.
130    pub fn next_intent_id(&self) -> Option<Uuid> {
131        self.next_intent_id
132    }
133
134    /// Set or clear the human-readable explanation.
135    pub fn set_reason(&mut self, reason: Option<String>) {
136        self.reason = reason;
137    }
138
139    /// Set or clear the resulting repository commit.
140    pub fn set_result_commit(&mut self, result_commit: Option<IntegrityHash>) {
141        self.result_commit = result_commit;
142    }
143
144    /// Set or clear the recommended follow-up intent id.
145    pub fn set_next_intent_id(&mut self, next_intent_id: Option<Uuid>) {
146        self.next_intent_id = next_intent_id;
147    }
148}
149
150impl fmt::Display for IntentEvent {
151    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152        write!(f, "IntentEvent: {}", self.header.object_id())
153    }
154}
155
156impl ObjectTrait for IntentEvent {
157    fn from_bytes(data: &[u8], _hash: ObjectHash) -> Result<Self, GitError>
158    where
159        Self: Sized,
160    {
161        serde_json::from_slice(data).map_err(|e| GitError::InvalidObjectInfo(e.to_string()))
162    }
163
164    fn get_type(&self) -> ObjectType {
165        ObjectType::IntentEvent
166    }
167
168    fn get_size(&self) -> usize {
169        match serde_json::to_vec(self) {
170            Ok(v) => v.len(),
171            Err(e) => {
172                tracing::warn!("failed to compute IntentEvent size: {}", e);
173                0
174            }
175        }
176    }
177
178    fn to_data(&self) -> Result<Vec<u8>, GitError> {
179        serde_json::to_vec(self).map_err(|e| GitError::InvalidObjectInfo(e.to_string()))
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    // Coverage:
188    // - completed intent event construction
189    // - optional rationale, result-commit attachment, and next-intent recommendation
190    // - forward-compatible parsing of unknown lifecycle labels
191
192    #[test]
193    fn test_intent_event_fields() {
194        // Scenario: a completed event preserves its kind and optional
195        // reason/result-commit metadata and recommended follow-up
196        // intent after in-memory mutation.
197        let actor = ActorRef::agent("planner").expect("actor");
198        let mut event = IntentEvent::new(actor, Uuid::from_u128(0x1), IntentEventKind::Completed)
199            .expect("event");
200        let hash = IntegrityHash::compute(b"commit");
201        let next_intent_id = Uuid::from_u128(0x2);
202        event.set_reason(Some("done".to_string()));
203        event.set_result_commit(Some(hash));
204        event.set_next_intent_id(Some(next_intent_id));
205
206        assert_eq!(event.kind(), &IntentEventKind::Completed);
207        assert_eq!(event.reason(), Some("done"));
208        assert_eq!(event.result_commit(), Some(&hash));
209        assert_eq!(event.next_intent_id(), Some(next_intent_id));
210    }
211
212    #[test]
213    fn test_intent_event_kind_accepts_unknown_string() {
214        // Scenario: deserializing an unrecognized lifecycle label falls
215        // back to `Other(String)` so newer producers remain compatible
216        // with older binaries.
217        let kind: IntentEventKind =
218            serde_json::from_str("\"waiting_for_human_review\"").expect("kind");
219
220        assert_eq!(
221            kind,
222            IntentEventKind::Other("waiting_for_human_review".to_string())
223        );
224    }
225}