1use std::backtrace::Backtrace;
2use std::sync::Arc;
3
4use serde::{Deserialize, Serialize};
5use serde_json::{Map, Value};
6
7use crate::{ErrorCode, Timestamp, sealed};
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub struct RecoverableSteps {
12 steps: Vec<String>,
13}
14
15impl RecoverableSteps {
16 #[must_use]
18 pub fn first(step: impl Into<String>) -> Self {
19 Self {
20 steps: vec![step.into()],
21 }
22 }
23
24 #[must_use]
26 pub fn all(steps: impl IntoIterator<Item = impl Into<String>>) -> Self {
27 Self {
28 steps: steps.into_iter().map(Into::into).collect(),
29 }
30 }
31
32 #[must_use]
34 pub fn first_step(&self) -> Option<&str> {
35 self.steps.first().map(String::as_str)
36 }
37
38 #[must_use]
40 pub fn steps(&self) -> &[String] {
41 &self.steps
42 }
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(tag = "kind", rename_all = "snake_case")]
48pub enum Remediation {
49 Recoverable {
51 steps: RecoverableSteps,
53 },
54 NotRecoverable {
56 justification: String,
58 },
59}
60
61impl Remediation {
62 #[must_use]
64 pub fn recoverable(
65 first: impl Into<String>,
66 rest: impl IntoIterator<Item = impl Into<String>>,
67 ) -> Self {
68 let mut steps = vec![first.into()];
69 steps.extend(rest.into_iter().map(Into::into));
70 Self::Recoverable {
71 steps: RecoverableSteps::all(steps),
72 }
73 }
74
75 #[must_use]
77 pub fn not_recoverable(justification: impl Into<String>) -> Self {
78 Self::NotRecoverable {
79 justification: justification.into(),
80 }
81 }
82}
83
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
86pub struct Diagnostic {
87 pub timestamp: Timestamp,
89 pub code: ErrorCode,
91 pub message: String,
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub cause: Option<String>,
96 pub remediation: Remediation,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub docs: Option<String>,
101 #[serde(default, skip_serializing_if = "Map::is_empty")]
102 pub details: Map<String, Value>,
104}
105
106pub trait DiagnosticInfo: sealed::Sealed {
108 fn diagnostic(&self) -> &Diagnostic;
110}
111
112#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
114pub struct DiagnosticSummary {
115 pub code: Option<ErrorCode>,
117 pub message: String,
119 pub at: Timestamp,
121}
122
123impl From<&Diagnostic> for DiagnosticSummary {
124 fn from(value: &Diagnostic) -> Self {
125 Self {
126 code: Some(value.code.clone()),
127 message: value.message.clone(),
128 at: value.timestamp,
129 }
130 }
131}
132
133#[derive(Debug, Serialize, Deserialize)]
135pub struct ErrorContext {
136 diagnostic: Diagnostic,
137 #[serde(skip, default = "capture_backtrace")]
138 backtrace: Backtrace,
139 #[serde(skip)]
140 source: Option<Arc<dyn std::error::Error + Send + Sync + 'static>>,
141}
142
143impl PartialEq for ErrorContext {
144 fn eq(&self, other: &Self) -> bool {
145 self.diagnostic == other.diagnostic
146 && self.source.as_ref().map(ToString::to_string)
147 == other.source.as_ref().map(ToString::to_string)
148 }
149}
150
151impl ErrorContext {
152 #[must_use]
154 pub fn new(code: ErrorCode, message: impl Into<String>, remediation: Remediation) -> Self {
155 Self {
156 diagnostic: Diagnostic {
157 timestamp: Timestamp::now_utc(),
158 code,
159 message: message.into(),
160 cause: None,
161 remediation,
162 docs: None,
163 details: Map::new(),
164 },
165 backtrace: capture_backtrace(),
166 source: None,
167 }
168 }
169
170 #[must_use]
172 pub fn cause(mut self, cause: impl Into<String>) -> Self {
173 self.diagnostic.cause = Some(cause.into());
174 self
175 }
176
177 #[must_use]
179 pub fn docs(mut self, docs: impl Into<String>) -> Self {
180 self.diagnostic.docs = Some(docs.into());
181 self
182 }
183
184 #[must_use]
186 pub fn detail(mut self, key: impl Into<String>, value: Value) -> Self {
187 self.diagnostic.details.insert(key.into(), value);
188 self
189 }
190
191 #[must_use]
193 pub fn source(mut self, source: Box<dyn std::error::Error + Send + Sync + 'static>) -> Self {
194 self.source = Some(Arc::from(source));
195 self
196 }
197
198 #[must_use]
200 pub fn diagnostic(&self) -> &Diagnostic {
201 &self.diagnostic
202 }
203
204 pub fn backtrace(&self) -> &Backtrace {
206 &self.backtrace
207 }
208}
209
210impl std::fmt::Display for ErrorContext {
211 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
212 write!(f, "{}", self.diagnostic.message)?;
213 if let Some(cause) = &self.diagnostic.cause {
214 write!(f, ": {cause}")?;
215 }
216 if let Some(source) = std::error::Error::source(self) {
217 write!(f, "; caused by: {source}")?;
218 }
219 Ok(())
220 }
221}
222
223impl std::error::Error for ErrorContext {
224 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
225 self.source
226 .as_deref()
227 .map(|source| source as &(dyn std::error::Error + 'static))
228 }
229}
230
231fn capture_backtrace() -> Backtrace {
232 Backtrace::capture()
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238 use serde_json::json;
239
240 use crate::{IdentityError, error_codes};
241
242 #[test]
243 fn remediation_construction_helpers_cover_both_variants() {
244 let recoverable = Remediation::recoverable("fix the input", ["retry"]);
245 let not_recoverable = Remediation::not_recoverable("manual intervention required");
246 let first_only = RecoverableSteps::first("first");
247 let all_steps = RecoverableSteps::all(["first", "second"]);
248
249 match recoverable {
250 Remediation::Recoverable { steps } => {
251 assert_eq!(steps.first_step(), Some("fix the input"));
252 assert_eq!(
253 steps.steps(),
254 ["fix the input".to_string(), "retry".to_string()]
255 );
256 }
257 Remediation::NotRecoverable { .. } => panic!("expected recoverable remediation"),
258 }
259
260 match not_recoverable {
261 Remediation::NotRecoverable { justification } => {
262 assert_eq!(justification, "manual intervention required");
263 }
264 Remediation::Recoverable { .. } => panic!("expected non-recoverable remediation"),
265 }
266
267 assert_eq!(first_only.first_step(), Some("first"));
268 assert_eq!(first_only.steps(), ["first".to_string()]);
269 assert_eq!(
270 all_steps.steps(),
271 ["first".to_string(), "second".to_string()]
272 );
273 }
274
275 #[test]
276 fn error_context_display_includes_cause_when_present() {
277 let error = ErrorContext::new(
278 error_codes::DIAGNOSTIC_INVALID,
279 "operation failed",
280 Remediation::recoverable("fix the config", ["retry"]),
281 )
282 .cause("missing field");
283
284 assert_eq!(error.to_string(), "operation failed: missing field");
285 }
286
287 #[test]
288 fn error_context_display_includes_source_chain_when_present() {
289 let error = ErrorContext::new(
290 error_codes::DIAGNOSTIC_INVALID,
291 "operation failed",
292 Remediation::not_recoverable("investigate"),
293 )
294 .cause("missing field")
295 .source(Box::new(std::io::Error::other("disk full")));
296
297 assert_eq!(
298 error.to_string(),
299 "operation failed: missing field; caused by: disk full"
300 );
301 }
302
303 #[test]
304 fn error_context_builder_sets_docs_details_and_source() {
305 let error = ErrorContext::new(
306 error_codes::DIAGNOSTIC_INVALID,
307 "operation failed",
308 Remediation::not_recoverable("investigate manually"),
309 )
310 .docs("https://example.test/failure")
311 .detail("attempt", json!(3))
312 .source(Box::new(std::io::Error::other("disk full")));
313
314 assert_eq!(
315 error.diagnostic().docs.as_deref(),
316 Some("https://example.test/failure")
317 );
318 assert_eq!(error.diagnostic().details.get("attempt"), Some(&json!(3)));
319 assert_eq!(
320 error.source.as_ref().map(ToString::to_string).as_deref(),
321 Some("disk full")
322 );
323 assert_eq!(
324 std::error::Error::source(&error)
325 .map(ToString::to_string)
326 .as_deref(),
327 Some("disk full")
328 );
329 assert!(!matches!(
330 error.backtrace().status(),
331 std::backtrace::BacktraceStatus::Unsupported
332 ));
333 }
334
335 #[test]
336 fn diagnostic_round_trips_through_serde() {
337 let original = Diagnostic {
338 timestamp: Timestamp::UNIX_EPOCH,
339 code: error_codes::DIAGNOSTIC_INVALID,
340 message: "diagnostic invalid".to_string(),
341 cause: Some("invalid example".to_string()),
342 remediation: Remediation::recoverable(
343 "fix the input",
344 ["rerun the command", "review the docs"],
345 ),
346 docs: Some("https://example.test/docs".to_string()),
347 details: Map::from_iter([("key".to_string(), json!("value"))]),
348 };
349 let encoded = serde_json::to_string(&original).expect("serialize diagnostic");
350 let decoded: Diagnostic = serde_json::from_str(&encoded).expect("deserialize diagnostic");
351 assert_eq!(decoded, original);
352 }
353
354 #[test]
355 fn diagnostic_summary_captures_code_and_message() {
356 let diagnostic = Diagnostic {
357 timestamp: Timestamp::UNIX_EPOCH,
358 code: error_codes::DIAGNOSTIC_INVALID,
359 message: "diagnostic invalid".to_string(),
360 cause: None,
361 remediation: Remediation::recoverable("fix the input", ["retry"]),
362 docs: None,
363 details: Map::new(),
364 };
365 let summary = DiagnosticSummary::from(&diagnostic);
366
367 assert_eq!(summary.code, Some(error_codes::DIAGNOSTIC_INVALID));
368 assert_eq!(summary.message, "diagnostic invalid");
369 assert_eq!(summary.at, Timestamp::UNIX_EPOCH);
370 }
371
372 #[test]
373 fn identity_error_exposes_inner_diagnostic() {
374 let context = ErrorContext::new(
375 error_codes::IDENTITY_RESOLUTION_FAILED,
376 "failed to resolve process identity",
377 Remediation::not_recoverable("configure a valid identity source"),
378 )
379 .detail("source", json!("test"));
380 let error = IdentityError(Box::new(context));
381
382 assert_eq!(
383 error.diagnostic().code,
384 error_codes::IDENTITY_RESOLUTION_FAILED
385 );
386 assert_eq!(
387 error.diagnostic().message,
388 "failed to resolve process identity"
389 );
390 assert_eq!(
391 error.diagnostic().details.get("source"),
392 Some(&json!("test"))
393 );
394 }
395}