nexo_tool_meta/
microapp_error.rs1use chrono::{DateTime, Utc};
22use serde::{Deserialize, Serialize};
23
24pub const MICROAPP_ERROR_NOTIFY_METHOD: &str = "nexo/notify/microapp_error";
26
27#[non_exhaustive]
31#[cfg_attr(feature = "ts-export", derive(ts_rs::TS), ts(export))]
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum MicroappErrorKind {
35 InitTimeout,
38 HandlerPanic,
42 Exit,
45 CapabilityDenied,
48}
49
50impl MicroappErrorKind {
51 pub fn as_wire_str(self) -> &'static str {
55 match self {
56 MicroappErrorKind::InitTimeout => "init_timeout",
57 MicroappErrorKind::HandlerPanic => "handler_panic",
58 MicroappErrorKind::Exit => "exit",
59 MicroappErrorKind::CapabilityDenied => "capability_denied",
60 }
61 }
62}
63
64#[cfg_attr(feature = "ts-export", derive(ts_rs::TS), ts(export))]
66#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
67pub struct MicroappError {
68 pub microapp_id: String,
70 pub kind: MicroappErrorKind,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub correlation_id: Option<String>,
77 pub occurred_at: DateTime<Utc>,
79 pub summary: String,
83 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub stack_trace: Option<String>,
88}
89
90impl MicroappError {
91 pub fn new(
94 microapp_id: impl Into<String>,
95 kind: MicroappErrorKind,
96 summary: impl Into<String>,
97 ) -> Self {
98 Self {
99 microapp_id: microapp_id.into(),
100 kind,
101 correlation_id: None,
102 occurred_at: Utc::now(),
103 summary: summary.into(),
104 stack_trace: None,
105 }
106 }
107
108 pub fn with_correlation_id(mut self, id: impl Into<String>) -> Self {
112 self.correlation_id = Some(id.into());
113 self
114 }
115
116 pub fn with_stack_trace(mut self, trace: impl Into<String>) -> Self {
119 self.stack_trace = Some(trace.into());
120 self
121 }
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127
128 #[test]
129 fn notify_method_constant_is_stable() {
130 assert_eq!(MICROAPP_ERROR_NOTIFY_METHOD, "nexo/notify/microapp_error");
133 }
134
135 #[test]
136 fn kind_serialises_snake_case() {
137 let json = serde_json::to_string(&MicroappErrorKind::InitTimeout).unwrap();
138 assert_eq!(json, "\"init_timeout\"");
139 let json = serde_json::to_string(&MicroappErrorKind::HandlerPanic).unwrap();
140 assert_eq!(json, "\"handler_panic\"");
141 let json = serde_json::to_string(&MicroappErrorKind::CapabilityDenied).unwrap();
142 assert_eq!(json, "\"capability_denied\"");
143 let json = serde_json::to_string(&MicroappErrorKind::Exit).unwrap();
144 assert_eq!(json, "\"exit\"");
145 }
146
147 #[test]
148 fn kind_wire_str_matches_serde() {
149 for k in [
150 MicroappErrorKind::InitTimeout,
151 MicroappErrorKind::HandlerPanic,
152 MicroappErrorKind::Exit,
153 MicroappErrorKind::CapabilityDenied,
154 ] {
155 let serde_json = serde_json::to_string(&k).unwrap();
156 let trimmed = serde_json.trim_matches('"');
158 assert_eq!(trimmed, k.as_wire_str());
159 }
160 }
161
162 #[test]
163 fn microapp_error_round_trips_full_payload() {
164 let err = MicroappError {
165 microapp_id: "agent-creator".into(),
166 kind: MicroappErrorKind::HandlerPanic,
167 correlation_id: Some("12".into()),
168 occurred_at: chrono::DateTime::parse_from_rfc3339("2026-05-02T12:00:00Z")
169 .unwrap()
170 .with_timezone(&Utc),
171 summary: "tool 'register' panicked: index out of bounds".into(),
172 stack_trace: Some("at handler.rs:42\nat dispatch.rs:118".into()),
173 };
174 let json = serde_json::to_string(&err).unwrap();
175 let back: MicroappError = serde_json::from_str(&json).unwrap();
176 assert_eq!(back, err);
177 }
178
179 #[test]
180 fn microapp_error_optional_fields_skip_when_none() {
181 let err = MicroappError::new(
182 "agent-creator",
183 MicroappErrorKind::Exit,
184 "exit code 137 (SIGKILL)",
185 );
186 let json = serde_json::to_string(&err).unwrap();
187 assert!(!json.contains("correlation_id"));
188 assert!(!json.contains("stack_trace"));
189 }
190
191 #[test]
192 fn builder_chain_sets_optional_fields() {
193 let err = MicroappError::new(
194 "x",
195 MicroappErrorKind::CapabilityDenied,
196 "needs agents_crud",
197 )
198 .with_correlation_id("app:abc-123")
199 .with_stack_trace("at admin.rs:99");
200 assert_eq!(err.correlation_id.as_deref(), Some("app:abc-123"));
201 assert_eq!(err.stack_trace.as_deref(), Some("at admin.rs:99"));
202 }
203}