Skip to main content

orion_error/core/
snapshot.rs

1use crate::{core::DomainReason, StructError};
2
3use super::{
4    context::OperationResult, report::DiagnosticReport, ErrorCategory, ErrorIdentityProvider,
5    ErrorMetadata, OperationContext, SourceFrame,
6};
7
8pub const STABLE_SNAPSHOT_SCHEMA_VERSION: &str = "orion-error.snapshot.v2";
9#[cfg_attr(feature = "serde", derive(serde::Serialize))]
10#[derive(Debug, Clone, PartialEq)]
11pub struct SnapshotContextFrame {
12    /// Stable root operation name.
13    pub target: Option<String>,
14    /// Action/phase captured by `doing(...)`.
15    pub action: Option<String>,
16    /// Resource/location captured by `at(...)`.
17    pub locator: Option<String>,
18    /// Stable path segments captured from runtime context.
19    pub path: Vec<String>,
20    /// Stable machine-readable metadata payload.
21    pub metadata: ErrorMetadata,
22    /// Compatibility projection of ad-hoc context key/value pairs.
23    pub fields: Vec<(String, String)>,
24    /// Compatibility projection of runtime scope result.
25    pub result: OperationResult,
26}
27
28#[cfg_attr(feature = "serde", derive(serde::Serialize))]
29#[derive(Debug, Clone, PartialEq)]
30pub struct StableSnapshotContextFrame {
31    pub target: Option<String>,
32    pub action: Option<String>,
33    pub locator: Option<String>,
34    pub path: Vec<String>,
35    pub metadata: ErrorMetadata,
36}
37
38#[cfg_attr(feature = "serde", derive(serde::Serialize))]
39#[derive(Debug, Clone, PartialEq)]
40pub struct SnapshotSourceFrame {
41    pub index: usize,
42    /// Stable human-facing summary for diagnostics and snapshot assertions.
43    pub message: String,
44    /// Compatibility projection of formatted display output.
45    pub display: Option<String>,
46    /// Compatibility projection of best-effort runtime type name.
47    pub type_name: Option<String>,
48    pub error_code: Option<i32>,
49    pub reason: Option<String>,
50    pub want: Option<String>,
51    pub path: Option<String>,
52    pub detail: Option<String>,
53    pub metadata: ErrorMetadata,
54    pub is_root_cause: bool,
55}
56
57#[cfg_attr(feature = "serde", derive(serde::Serialize))]
58#[derive(Debug, Clone, PartialEq)]
59pub struct StableSnapshotSourceFrame {
60    pub index: usize,
61    pub message: String,
62    pub error_code: Option<i32>,
63    pub reason: Option<String>,
64    pub want: Option<String>,
65    pub path: Option<String>,
66    pub detail: Option<String>,
67    pub metadata: ErrorMetadata,
68    pub is_root_cause: bool,
69}
70
71/// Stable machine-readable snapshot view derived from `StructError`.
72///
73/// This object is intentionally separate from runtime propagation semantics.
74/// It carries exported diagnostic data, but does not implement `StdError`
75/// or own any runtime source object handles.
76#[derive(Debug, Clone, PartialEq)]
77pub struct ErrorSnapshot {
78    pub reason: String,
79    pub detail: Option<String>,
80    pub position: Option<String>,
81    pub want: Option<String>,
82    pub path: Option<String>,
83    pub category: ErrorCategory,
84    pub code: String,
85    pub context: Vec<SnapshotContextFrame>,
86    pub root_metadata: ErrorMetadata,
87    pub source_frames: Vec<SnapshotSourceFrame>,
88}
89
90#[cfg_attr(feature = "serde", derive(serde::Serialize))]
91#[derive(Debug, Clone, PartialEq)]
92pub struct StableErrorSnapshot {
93    pub schema_version: &'static str,
94    pub reason: String,
95    pub detail: Option<String>,
96    pub position: Option<String>,
97    pub want: Option<String>,
98    pub path: Option<String>,
99    #[cfg_attr(feature = "serde", serde(skip))]
100    pub category: ErrorCategory,
101    #[cfg_attr(feature = "serde", serde(skip))]
102    pub code: String,
103    pub context: Vec<StableSnapshotContextFrame>,
104    pub root_metadata: ErrorMetadata,
105    pub source_frames: Vec<StableSnapshotSourceFrame>,
106}
107
108/// Identity-first snapshot view.
109///
110/// This view keeps `code` and `category` available for governance, testing,
111/// policy decisions, and protocol projections without changing the stable
112/// snapshot export contract.
113#[cfg_attr(feature = "serde", derive(serde::Serialize))]
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct ErrorIdentity {
116    pub code: String,
117    pub category: ErrorCategory,
118    pub reason: String,
119    pub detail: Option<String>,
120    pub position: Option<String>,
121    pub want: Option<String>,
122    pub path: Option<String>,
123}
124
125impl ErrorSnapshot {
126    pub fn stable_context(&self) -> &[SnapshotContextFrame] {
127        &self.context
128    }
129
130    pub fn stable_source_frames(&self) -> &[SnapshotSourceFrame] {
131        &self.source_frames
132    }
133
134    pub fn root_source_frame(&self) -> Option<&SnapshotSourceFrame> {
135        self.source_frames.iter().find(|frame| frame.is_root_cause)
136    }
137
138    pub fn stable_export(&self) -> StableErrorSnapshot {
139        self.clone().into_stable_export()
140    }
141
142    pub fn into_stable_export(self) -> StableErrorSnapshot {
143        StableErrorSnapshot {
144            schema_version: STABLE_SNAPSHOT_SCHEMA_VERSION,
145            reason: self.reason,
146            detail: self.detail,
147            position: self.position,
148            want: self.want,
149            path: self.path,
150            category: self.category,
151            code: self.code,
152            context: self.context.into_iter().map(Into::into).collect(),
153            root_metadata: self.root_metadata,
154            source_frames: self.source_frames.into_iter().map(Into::into).collect(),
155        }
156    }
157
158    #[cfg(feature = "serde_json")]
159    pub fn to_stable_snapshot_json(&self) -> serde_json::Result<serde_json::Value> {
160        serde_json::to_value(self.stable_export())
161    }
162
163    pub fn report(&self) -> DiagnosticReport {
164        self.clone().into_report()
165    }
166
167    pub fn into_report(self) -> DiagnosticReport {
168        DiagnosticReport {
169            reason: self.reason,
170            detail: self.detail,
171            position: self.position,
172            want: self.want,
173            path: self.path,
174            context: self.context.into_iter().map(Into::into).collect(),
175            root_metadata: self.root_metadata,
176            source_frames: self.source_frames.into_iter().map(Into::into).collect(),
177        }
178    }
179}
180
181impl StableErrorSnapshot {
182    pub fn report(&self) -> DiagnosticReport {
183        DiagnosticReport {
184            reason: self.reason.clone(),
185            detail: self.detail.clone(),
186            position: self.position.clone(),
187            want: self.want.clone(),
188            path: self.path.clone(),
189            context: self.context.iter().cloned().map(Into::into).collect(),
190            root_metadata: self.root_metadata.clone(),
191            source_frames: self.source_frames.iter().cloned().map(Into::into).collect(),
192        }
193    }
194
195    pub fn into_report(self) -> DiagnosticReport {
196        DiagnosticReport {
197            reason: self.reason,
198            detail: self.detail,
199            position: self.position,
200            want: self.want,
201            path: self.path,
202            context: self.context.into_iter().map(Into::into).collect(),
203            root_metadata: self.root_metadata,
204            source_frames: self.source_frames.into_iter().map(Into::into).collect(),
205        }
206    }
207}
208
209impl SnapshotContextFrame {
210    pub fn stable_export(&self) -> StableSnapshotContextFrame {
211        self.clone().into()
212    }
213}
214
215impl SnapshotSourceFrame {
216    pub fn stable_export(&self) -> StableSnapshotSourceFrame {
217        self.clone().into()
218    }
219}
220
221impl From<SnapshotContextFrame> for StableSnapshotContextFrame {
222    fn from(value: SnapshotContextFrame) -> Self {
223        StableSnapshotContextFrame {
224            target: value.target,
225            action: value.action,
226            locator: value.locator,
227            path: value.path,
228            metadata: value.metadata,
229        }
230    }
231}
232
233impl From<&SnapshotContextFrame> for StableSnapshotContextFrame {
234    fn from(value: &SnapshotContextFrame) -> Self {
235        value.clone().into()
236    }
237}
238
239impl From<StableSnapshotContextFrame> for SnapshotContextFrame {
240    fn from(value: StableSnapshotContextFrame) -> Self {
241        SnapshotContextFrame {
242            target: value.target,
243            action: value.action,
244            locator: value.locator,
245            path: value.path,
246            metadata: value.metadata,
247            fields: Vec::new(),
248            result: OperationResult::Fail,
249        }
250    }
251}
252
253impl From<&StableSnapshotContextFrame> for SnapshotContextFrame {
254    fn from(value: &StableSnapshotContextFrame) -> Self {
255        value.clone().into()
256    }
257}
258
259impl From<SnapshotSourceFrame> for StableSnapshotSourceFrame {
260    fn from(value: SnapshotSourceFrame) -> Self {
261        StableSnapshotSourceFrame {
262            index: value.index,
263            message: value.message,
264            error_code: value.error_code,
265            reason: value.reason,
266            want: value.want,
267            path: value.path,
268            detail: value.detail,
269            metadata: value.metadata,
270            is_root_cause: value.is_root_cause,
271        }
272    }
273}
274
275impl From<&SnapshotSourceFrame> for StableSnapshotSourceFrame {
276    fn from(value: &SnapshotSourceFrame) -> Self {
277        value.clone().into()
278    }
279}
280
281impl From<StableSnapshotSourceFrame> for SnapshotSourceFrame {
282    fn from(value: StableSnapshotSourceFrame) -> Self {
283        SnapshotSourceFrame {
284            index: value.index,
285            message: value.message,
286            display: None,
287            type_name: None,
288            error_code: value.error_code,
289            reason: value.reason,
290            want: value.want,
291            path: value.path,
292            detail: value.detail,
293            metadata: value.metadata,
294            is_root_cause: value.is_root_cause,
295        }
296    }
297}
298
299impl From<&StableSnapshotSourceFrame> for SnapshotSourceFrame {
300    fn from(value: &StableSnapshotSourceFrame) -> Self {
301        value.clone().into()
302    }
303}
304
305impl From<OperationContext> for SnapshotContextFrame {
306    fn from(value: OperationContext) -> Self {
307        Self {
308            target: value.target().clone(),
309            action: value.action().clone(),
310            locator: value.locator().clone(),
311            path: value.normalized_path_segments(),
312            metadata: value.metadata().clone(),
313            fields: value.context().items.clone(),
314            result: value.result().clone(),
315        }
316    }
317}
318
319impl From<SnapshotContextFrame> for OperationContext {
320    fn from(value: SnapshotContextFrame) -> Self {
321        let mut ctx = value
322            .target
323            .clone()
324            .map(OperationContext::from_target)
325            .unwrap_or_default();
326        ctx.replace_target_for_report(value.target);
327        ctx.replace_action_for_report(value.action);
328        ctx.replace_locator_for_report(value.locator);
329        ctx.replace_path_for_report(value.path);
330        ctx.context_mut_for_report().items = value.fields;
331        ctx.replace_metadata_for_report(value.metadata);
332        match value.result {
333            OperationResult::Suc => ctx.mark_suc(),
334            OperationResult::Fail => {}
335            OperationResult::Cancel => ctx.mark_cancel(),
336        }
337        ctx
338    }
339}
340
341impl From<StableSnapshotContextFrame> for OperationContext {
342    fn from(value: StableSnapshotContextFrame) -> Self {
343        SnapshotContextFrame::from(value).into()
344    }
345}
346
347impl From<&StableSnapshotContextFrame> for OperationContext {
348    fn from(value: &StableSnapshotContextFrame) -> Self {
349        value.clone().into()
350    }
351}
352
353impl From<SourceFrame> for SnapshotSourceFrame {
354    fn from(value: SourceFrame) -> Self {
355        Self {
356            index: value.index,
357            message: value.message,
358            display: value.display,
359            type_name: value.type_name,
360            error_code: value.error_code,
361            reason: value.reason,
362            want: value.want,
363            path: value.path,
364            detail: value.detail,
365            metadata: value.metadata,
366            is_root_cause: value.is_root_cause,
367        }
368    }
369}
370
371impl From<SnapshotSourceFrame> for SourceFrame {
372    fn from(value: SnapshotSourceFrame) -> Self {
373        Self {
374            index: value.index,
375            message: value.message,
376            display: value.display,
377            debug: String::new(),
378            type_name: value.type_name,
379            error_code: value.error_code,
380            reason: value.reason,
381            want: value.want,
382            path: value.path,
383            detail: value.detail,
384            metadata: value.metadata,
385            is_root_cause: value.is_root_cause,
386        }
387    }
388}
389
390impl From<StableSnapshotSourceFrame> for SourceFrame {
391    fn from(value: StableSnapshotSourceFrame) -> Self {
392        SnapshotSourceFrame::from(value).into()
393    }
394}
395
396impl From<&StableSnapshotSourceFrame> for SourceFrame {
397    fn from(value: &StableSnapshotSourceFrame) -> Self {
398        value.clone().into()
399    }
400}
401
402impl<T> StructError<T>
403where
404    T: DomainReason + ErrorIdentityProvider,
405{
406    pub fn snapshot(&self) -> ErrorSnapshot {
407        ErrorSnapshot {
408            reason: self.reason().to_string(),
409            detail: self.detail().clone(),
410            position: self.position().clone(),
411            want: self.target_main(),
412            path: self.target_path(),
413            category: self.error_category(),
414            code: self.stable_code().to_string(),
415            context: self.contexts().iter().cloned().map(Into::into).collect(),
416            root_metadata: self.context_metadata(),
417            source_frames: self
418                .source_frames()
419                .iter()
420                .cloned()
421                .map(Into::into)
422                .collect(),
423        }
424    }
425
426    pub fn into_snapshot(self) -> ErrorSnapshot {
427        self.snapshot()
428    }
429
430    pub fn identity_snapshot(&self) -> ErrorIdentity {
431        ErrorIdentity {
432            code: self.stable_code().to_string(),
433            category: self.error_category(),
434            reason: self.reason().to_string(),
435            detail: self.detail().clone(),
436            position: self.position().clone(),
437            want: self.target_main(),
438            path: self.target_path(),
439        }
440    }
441}
442
443impl<T> From<&StructError<T>> for ErrorSnapshot
444where
445    T: DomainReason + ErrorIdentityProvider,
446{
447    fn from(value: &StructError<T>) -> Self {
448        value.snapshot()
449    }
450}
451
452impl<T> From<StructError<T>> for ErrorSnapshot
453where
454    T: DomainReason + ErrorIdentityProvider,
455{
456    fn from(value: StructError<T>) -> Self {
457        value.into_snapshot()
458    }
459}
460
461impl<T> From<&StructError<T>> for StableErrorSnapshot
462where
463    T: DomainReason + ErrorIdentityProvider,
464{
465    fn from(value: &StructError<T>) -> Self {
466        value.snapshot().into_stable_export()
467    }
468}
469
470impl<T> From<StructError<T>> for StableErrorSnapshot
471where
472    T: DomainReason + ErrorIdentityProvider,
473{
474    fn from(value: StructError<T>) -> Self {
475        value.into_snapshot().into_stable_export()
476    }
477}
478
479impl From<&ErrorSnapshot> for StableErrorSnapshot {
480    fn from(value: &ErrorSnapshot) -> Self {
481        value.stable_export()
482    }
483}
484
485impl From<ErrorSnapshot> for StableErrorSnapshot {
486    fn from(value: ErrorSnapshot) -> Self {
487        value.into_stable_export()
488    }
489}
490
491#[cfg(test)]
492mod tests {
493    use crate::{
494        core::{context::ContextRecord, DomainReason, ErrorMetadata, SourceFrame},
495        ErrorCategory, ErrorCode, ErrorIdentityProvider, OperationContext, StructError, UvsReason,
496    };
497
498    use super::{
499        DiagnosticReport, ErrorSnapshot, SnapshotContextFrame, SnapshotSourceFrame,
500        StableErrorSnapshot, StableSnapshotContextFrame, StableSnapshotSourceFrame,
501        STABLE_SNAPSHOT_SCHEMA_VERSION,
502    };
503
504    #[derive(Debug, Clone, PartialEq, thiserror::Error)]
505    enum TestReason {
506        #[error("test error")]
507        TestError,
508        #[error("{0}")]
509        Uvs(UvsReason),
510    }
511
512    impl From<UvsReason> for TestReason {
513        fn from(value: UvsReason) -> Self {
514            Self::Uvs(value)
515        }
516    }
517
518    impl DomainReason for TestReason {}
519
520    impl ErrorCode for TestReason {
521        fn error_code(&self) -> i32 {
522            match self {
523                TestReason::TestError => 1001,
524                TestReason::Uvs(reason) => reason.error_code(),
525            }
526        }
527    }
528
529    impl ErrorIdentityProvider for TestReason {
530        fn stable_code(&self) -> &'static str {
531            match self {
532                TestReason::TestError => "test.test_error",
533                TestReason::Uvs(reason) => reason.stable_code(),
534            }
535        }
536
537        fn error_category(&self) -> ErrorCategory {
538            match self {
539                TestReason::TestError => ErrorCategory::Logic,
540                TestReason::Uvs(reason) => reason.error_category(),
541            }
542        }
543    }
544
545    #[test]
546    fn test_snapshot_captures_runtime_fields_and_source_frames() {
547        let source = StructError::from(TestReason::TestError).with_context(
548            OperationContext::doing("load defaults").with_meta("config.kind", "sink_defaults"),
549        );
550        let err = StructError::from(TestReason::Uvs(UvsReason::system_error()))
551            .with_detail("engine bootstrap failed")
552            .with_position("src/main.rs:42")
553            .with_context(
554                OperationContext::doing("start engine").with_meta("component.name", "engine"),
555            )
556            .with_struct_source(source);
557
558        let snapshot = err.snapshot();
559
560        assert_eq!(snapshot.reason, "system error");
561        assert_eq!(snapshot.detail.as_deref(), Some("engine bootstrap failed"));
562        assert_eq!(snapshot.position.as_deref(), Some("src/main.rs:42"));
563        assert_eq!(snapshot.want.as_deref(), Some("start engine"));
564        assert_eq!(snapshot.context[0].target.as_deref(), Some("start engine"));
565        assert_eq!(
566            snapshot.root_metadata.get_str("component.name"),
567            Some("engine")
568        );
569        assert_eq!(
570            snapshot.source_frames[0].metadata.get_str("config.kind"),
571            Some("sink_defaults")
572        );
573    }
574
575    #[test]
576    fn test_identity_snapshot_captures_stable_identity_fields() {
577        let err = StructError::from(TestReason::Uvs(UvsReason::system_error()))
578            .with_detail("engine bootstrap failed")
579            .with_position("src/main.rs:42")
580            .with_context(OperationContext::doing("start engine"));
581
582        let identity = err.identity_snapshot();
583
584        assert_eq!(identity.code, "sys.io_error");
585        assert_eq!(identity.category, ErrorCategory::Sys);
586        assert_eq!(identity.reason, "system error");
587        assert_eq!(identity.detail.as_deref(), Some("engine bootstrap failed"));
588        assert_eq!(identity.position.as_deref(), Some("src/main.rs:42"));
589        assert_eq!(identity.want.as_deref(), Some("start engine"));
590        assert_eq!(identity.path.as_deref(), Some("start engine"));
591    }
592
593    #[test]
594    fn test_snapshot_preserves_action_and_locator_context_fields() {
595        let mut ctx = OperationContext::at("config.toml");
596        ctx.with_doing("parse config");
597
598        let err = StructError::from(TestReason::Uvs(UvsReason::system_error()))
599            .with_context(
600                OperationContext::doing("load config").with_meta("component.name", "engine"),
601            )
602            .with_context(ctx);
603
604        let snapshot = err.snapshot();
605
606        assert_eq!(snapshot.context[0].action.as_deref(), Some("load config"));
607        assert_eq!(snapshot.context[1].action.as_deref(), Some("parse config"));
608        assert_eq!(snapshot.context[1].locator.as_deref(), Some("config.toml"));
609
610        let report = snapshot.into_report();
611        assert_eq!(report.context[1].action().as_deref(), Some("parse config"));
612        assert_eq!(report.context[1].locator().as_deref(), Some("config.toml"));
613    }
614
615    #[test]
616    fn test_snapshot_report_conversion_preserves_payload() {
617        let snapshot = ErrorSnapshot {
618            reason: "system error".to_string(),
619            detail: Some("engine bootstrap failed".to_string()),
620            position: Some("src/main.rs:42".to_string()),
621            want: Some("start engine".to_string()),
622            path: Some("start engine / load defaults".to_string()),
623            context: vec![SnapshotContextFrame {
624                target: Some("start engine".to_string()),
625                action: None,
626                locator: None,
627                path: vec!["start engine".to_string()],
628                metadata: ErrorMetadata::new(),
629                fields: vec![],
630                result: crate::core::context::OperationResult::Fail,
631            }],
632            root_metadata: {
633                let mut metadata = ErrorMetadata::new();
634                metadata.insert("component.name", "engine");
635                metadata
636            },
637            source_frames: vec![],
638            category: ErrorCategory::Sys,
639            code: "sys.test_error".to_string(),
640        };
641
642        let report = snapshot.report();
643
644        assert_eq!(report.reason, snapshot.reason);
645        assert_eq!(report.detail, snapshot.detail);
646        assert_eq!(report.position, snapshot.position);
647        assert_eq!(report.want, snapshot.want);
648        assert_eq!(report.path, snapshot.path);
649        assert_eq!(
650            report.context,
651            snapshot
652                .context
653                .clone()
654                .into_iter()
655                .map(Into::into)
656                .collect::<Vec<OperationContext>>()
657        );
658        assert_eq!(report.root_metadata, snapshot.root_metadata);
659        assert_eq!(
660            report.source_frames,
661            snapshot
662                .source_frames
663                .clone()
664                .into_iter()
665                .map(Into::into)
666                .collect::<Vec<SourceFrame>>()
667        );
668    }
669
670    #[test]
671    fn test_snapshot_from_struct_error_matches_snapshot_method() {
672        let err = StructError::from(TestReason::TestError)
673            .with_detail("engine bootstrap failed")
674            .with_context(OperationContext::doing("start engine"));
675
676        let via_method = err.snapshot();
677        let via_from = ErrorSnapshot::from(&err);
678
679        assert_eq!(via_from, via_method);
680    }
681
682    #[test]
683    fn test_snapshot_from_owned_struct_error_matches_snapshot_method() {
684        let err = StructError::from(TestReason::TestError)
685            .with_detail("engine bootstrap failed")
686            .with_context(OperationContext::doing("start engine"));
687
688        let via_method = err.snapshot();
689        let via_from = ErrorSnapshot::from(err);
690
691        assert_eq!(via_from, via_method);
692    }
693
694    #[test]
695    fn test_struct_error_into_snapshot_matches_snapshot_method() {
696        let err = StructError::from(TestReason::TestError)
697            .with_detail("engine bootstrap failed")
698            .with_context(OperationContext::doing("start engine"));
699
700        let via_method = err.snapshot();
701        let via_into = err.into_snapshot();
702
703        assert_eq!(via_into, via_method);
704    }
705
706    #[test]
707    fn test_snapshot_into_report_matches_borrowed_report() {
708        let snapshot = ErrorSnapshot {
709            reason: "system error".to_string(),
710            detail: Some("engine bootstrap failed".to_string()),
711            position: Some("src/main.rs:42".to_string()),
712            want: Some("start engine".to_string()),
713            path: Some("start engine".to_string()),
714            context: vec![SnapshotContextFrame {
715                target: Some("start engine".to_string()),
716                action: None,
717                locator: None,
718                path: vec!["start engine".to_string()],
719                metadata: ErrorMetadata::new(),
720                fields: vec![("tenant".to_string(), "alpha".to_string())],
721                result: crate::core::context::OperationResult::Fail,
722            }],
723            root_metadata: ErrorMetadata::new(),
724            source_frames: vec![SnapshotSourceFrame {
725                index: 0,
726                message: "db unavailable".to_string(),
727                display: Some("db unavailable".to_string()),
728                type_name: Some("std::io::Error".to_string()),
729                error_code: None,
730                reason: None,
731                want: Some("load config".to_string()),
732                path: Some("load config / read".to_string()),
733                detail: Some("inner detail".to_string()),
734                metadata: ErrorMetadata::new(),
735                is_root_cause: true,
736            }],
737            category: ErrorCategory::Sys,
738            code: "sys.test_error".to_string(),
739        };
740
741        let via_borrowed = snapshot.report();
742        let via_owned = snapshot.clone().into_report();
743        let via_from = DiagnosticReport::from(snapshot);
744
745        assert_eq!(via_owned, via_borrowed);
746        assert_eq!(via_from, via_borrowed);
747    }
748
749    #[test]
750    fn test_snapshot_stable_helpers_prefer_snapshot_native_frames() {
751        let source = StructError::from(TestReason::TestError)
752            .with_detail("inner detail")
753            .with_context(
754                OperationContext::doing("load defaults").with_meta("config.kind", "sink_defaults"),
755            );
756        let err = StructError::from(TestReason::Uvs(UvsReason::system_error()))
757            .with_detail("outer detail")
758            .with_context(OperationContext::doing("start engine"))
759            .with_struct_source(source);
760
761        let snapshot = err.snapshot();
762
763        assert_eq!(snapshot.stable_context(), snapshot.context.as_slice());
764        assert_eq!(
765            snapshot.stable_source_frames(),
766            snapshot.source_frames.as_slice()
767        );
768        assert_eq!(snapshot.root_source_frame().unwrap().message, "test error");
769        assert_eq!(
770            snapshot
771                .root_source_frame()
772                .unwrap()
773                .metadata
774                .get_str("config.kind"),
775            Some("sink_defaults")
776        );
777    }
778
779    #[test]
780    fn test_snapshot_stable_export_strips_compat_projection_fields() {
781        let source = StructError::from(TestReason::TestError)
782            .with_detail("inner detail")
783            .with_context(
784                OperationContext::doing("load defaults").with_meta("config.kind", "sink_defaults"),
785            );
786        let mut outer = OperationContext::at("engine.toml");
787        outer.with_doing("start engine");
788        let err = StructError::from(TestReason::Uvs(UvsReason::system_error()))
789            .with_detail("outer detail")
790            .with_context(outer)
791            .with_struct_source(source);
792
793        let snapshot = err.snapshot();
794        let stable = snapshot.stable_export();
795
796        assert_eq!(stable.schema_version, STABLE_SNAPSHOT_SCHEMA_VERSION);
797        assert_eq!(stable.reason, snapshot.reason);
798        assert_eq!(stable.context[0].target.as_deref(), Some("start engine"));
799        assert_eq!(stable.context[0].action.as_deref(), Some("start engine"));
800        assert_eq!(stable.context[0].locator.as_deref(), Some("engine.toml"));
801        assert_eq!(
802            stable.context[0].path,
803            vec!["start engine".to_string(), "engine.toml".to_string()]
804        );
805        assert_eq!(
806            stable.source_frames[0].message,
807            snapshot.source_frames[0].message
808        );
809        assert_eq!(
810            stable.source_frames[0].metadata.get_str("config.kind"),
811            Some("sink_defaults")
812        );
813    }
814
815    #[test]
816    fn test_snapshot_into_stable_export_matches_borrowed_stable_export() {
817        let snapshot = ErrorSnapshot {
818            reason: "system error".to_string(),
819            detail: Some("outer detail".to_string()),
820            position: Some("src/main.rs:42".to_string()),
821            want: Some("start engine".to_string()),
822            path: Some("start engine".to_string()),
823            context: vec![SnapshotContextFrame {
824                target: Some("start engine".to_string()),
825                action: None,
826                locator: None,
827                path: vec!["start engine".to_string()],
828                metadata: ErrorMetadata::new(),
829                fields: vec![("tenant".to_string(), "alpha".to_string())],
830                result: crate::core::context::OperationResult::Fail,
831            }],
832            root_metadata: ErrorMetadata::new(),
833            source_frames: vec![SnapshotSourceFrame {
834                index: 0,
835                message: "db unavailable".to_string(),
836                display: Some("db unavailable".to_string()),
837                type_name: Some("std::io::Error".to_string()),
838                error_code: None,
839                reason: None,
840                want: Some("load config".to_string()),
841                path: Some("load config / read".to_string()),
842                detail: Some("inner detail".to_string()),
843                metadata: ErrorMetadata::new(),
844                is_root_cause: true,
845            }],
846            category: ErrorCategory::Sys,
847            code: "sys.test_error".to_string(),
848        };
849
850        let via_borrowed = snapshot.stable_export();
851        let via_owned = snapshot.clone().into_stable_export();
852        let via_from_borrowed = StableErrorSnapshot::from(&snapshot);
853        let via_from_owned = StableErrorSnapshot::from(snapshot);
854
855        assert_eq!(via_owned, via_borrowed);
856        assert_eq!(via_from_borrowed, via_borrowed);
857        assert_eq!(via_from_owned, via_borrowed);
858        assert_eq!(via_borrowed.schema_version, STABLE_SNAPSHOT_SCHEMA_VERSION);
859    }
860
861    #[test]
862    fn test_stable_snapshot_from_struct_error_matches_snapshot_stable_export() {
863        let source = StructError::from(TestReason::TestError)
864            .with_detail("inner detail")
865            .with_context(
866                OperationContext::doing("load defaults").with_meta("config.kind", "sink_defaults"),
867            );
868        let err = StructError::from(TestReason::Uvs(UvsReason::system_error()))
869            .with_detail("outer detail")
870            .with_context(OperationContext::doing("start engine"))
871            .with_struct_source(source);
872
873        let via_method = err.snapshot().stable_export();
874        let via_borrowed = StableErrorSnapshot::from(&err);
875        let via_owned = StableErrorSnapshot::from(err);
876
877        assert_eq!(via_borrowed, via_method);
878        assert_eq!(via_owned, via_method);
879    }
880
881    #[test]
882    fn test_snapshot_frame_stable_from_matches_stable_export() {
883        let context = SnapshotContextFrame {
884            target: Some("start engine".to_string()),
885            action: None,
886            locator: None,
887            path: vec!["start engine".to_string()],
888            metadata: ErrorMetadata::new(),
889            fields: vec![("tenant".to_string(), "alpha".to_string())],
890            result: crate::core::context::OperationResult::Fail,
891        };
892        let source = SnapshotSourceFrame {
893            index: 0,
894            message: "db unavailable".to_string(),
895            display: Some("db unavailable".to_string()),
896            type_name: Some("std::io::Error".to_string()),
897            error_code: None,
898            reason: None,
899            want: Some("load config".to_string()),
900            path: Some("load config / read".to_string()),
901            detail: Some("inner detail".to_string()),
902            metadata: ErrorMetadata::new(),
903            is_root_cause: true,
904        };
905
906        assert_eq!(
907            StableSnapshotContextFrame::from(&context),
908            context.stable_export()
909        );
910        assert_eq!(
911            StableSnapshotContextFrame::from(context.clone()),
912            context.stable_export()
913        );
914        assert_eq!(
915            StableSnapshotSourceFrame::from(&source),
916            source.stable_export()
917        );
918        assert_eq!(
919            StableSnapshotSourceFrame::from(source.clone()),
920            source.stable_export()
921        );
922    }
923
924    #[test]
925    fn test_stable_snapshot_into_report_matches_report() {
926        let stable = StableErrorSnapshot {
927            schema_version: STABLE_SNAPSHOT_SCHEMA_VERSION,
928            reason: "system error".to_string(),
929            detail: Some("outer detail".to_string()),
930            position: None,
931            want: Some("start engine".to_string()),
932            path: Some("start engine".to_string()),
933            context: vec![StableSnapshotContextFrame {
934                target: Some("start engine".to_string()),
935                action: None,
936                locator: None,
937                path: vec!["start engine".to_string()],
938                metadata: ErrorMetadata::new(),
939            }],
940            root_metadata: ErrorMetadata::new(),
941            source_frames: vec![StableSnapshotSourceFrame {
942                index: 0,
943                message: "db unavailable".to_string(),
944                error_code: None,
945                reason: None,
946                want: Some("load config".to_string()),
947                path: Some("load config / read".to_string()),
948                detail: Some("inner detail".to_string()),
949                metadata: ErrorMetadata::new(),
950                is_root_cause: true,
951            }],
952            category: ErrorCategory::Sys,
953            code: "sys.test_error".to_string(),
954        };
955
956        let via_method = stable.report();
957        let via_owned = stable.clone().into_report();
958
959        assert_eq!(via_owned, via_method);
960    }
961
962    #[test]
963    fn test_stable_frame_to_compat_frame_defaults_compat_fields() {
964        let context = StableSnapshotContextFrame {
965            target: Some("start engine".to_string()),
966            action: None,
967            locator: None,
968            path: vec!["start engine".to_string()],
969            metadata: ErrorMetadata::new(),
970        };
971        let source = StableSnapshotSourceFrame {
972            index: 0,
973            message: "db unavailable".to_string(),
974            error_code: None,
975            reason: None,
976            want: Some("load config".to_string()),
977            path: Some("load config / read".to_string()),
978            detail: Some("inner detail".to_string()),
979            metadata: ErrorMetadata::new(),
980            is_root_cause: true,
981        };
982
983        let compat_context = SnapshotContextFrame::from(&context);
984        let compat_source = SnapshotSourceFrame::from(&source);
985
986        assert_eq!(compat_context.target, context.target);
987        assert_eq!(compat_context.path, context.path);
988        assert_eq!(compat_context.fields, Vec::<(String, String)>::new());
989        assert_eq!(
990            compat_context.result,
991            crate::core::context::OperationResult::Fail
992        );
993        assert_eq!(compat_source.message, source.message);
994        assert_eq!(compat_source.display, None);
995        assert_eq!(compat_source.type_name, None);
996    }
997
998    #[test]
999    fn test_snapshot_context_frame_roundtrip_to_operation_context() {
1000        let mut ctx = OperationContext::doing("start engine");
1001        ctx.with_doing("load defaults");
1002        ctx.record("tenant", "alpha");
1003        ctx.record_meta("component.name", "engine");
1004
1005        let snapshot_frame = SnapshotContextFrame::from(ctx.clone());
1006        let roundtrip: OperationContext = snapshot_frame.clone().into();
1007
1008        assert_eq!(snapshot_frame.target.as_deref(), Some("start engine"));
1009        assert_eq!(
1010            snapshot_frame.path,
1011            vec!["start engine".to_string(), "load defaults".to_string()]
1012        );
1013        assert_eq!(roundtrip.target().as_deref(), Some("start engine"));
1014        assert_eq!(
1015            roundtrip.path(),
1016            vec!["start engine".to_string(), "load defaults".to_string()]
1017        );
1018        assert_eq!(
1019            roundtrip.metadata().get_str("component.name"),
1020            Some("engine")
1021        );
1022        assert_eq!(
1023            roundtrip.context().items,
1024            vec![("tenant".to_string(), "alpha".to_string())]
1025        );
1026    }
1027
1028    #[test]
1029    fn test_snapshot_context_frame_roundtrip_normalizes_action_locator_path() {
1030        let mut ctx = OperationContext::at("engine.toml");
1031        ctx.with_doing("start engine");
1032
1033        let snapshot_frame = SnapshotContextFrame::from(ctx);
1034        let roundtrip: OperationContext = snapshot_frame.clone().into();
1035
1036        assert_eq!(snapshot_frame.target.as_deref(), Some("start engine"));
1037        assert_eq!(snapshot_frame.action.as_deref(), Some("start engine"));
1038        assert_eq!(snapshot_frame.locator.as_deref(), Some("engine.toml"));
1039        assert_eq!(
1040            snapshot_frame.path,
1041            vec!["start engine".to_string(), "engine.toml".to_string()]
1042        );
1043        assert_eq!(
1044            roundtrip.path(),
1045            vec!["start engine".to_string(), "engine.toml".to_string()]
1046        );
1047        assert_eq!(
1048            roundtrip.path_string().as_deref(),
1049            Some("start engine / engine.toml")
1050        );
1051    }
1052
1053    #[test]
1054    fn test_snapshot_source_frame_roundtrip_to_report_frame() {
1055        let frame = SnapshotSourceFrame {
1056            index: 0,
1057            message: "db unavailable".to_string(),
1058            display: Some("db unavailable".to_string()),
1059            type_name: Some("std::io::Error".to_string()),
1060            error_code: None,
1061            reason: None,
1062            want: Some("load config".to_string()),
1063            path: Some("load config / read".to_string()),
1064            detail: Some("inner detail".to_string()),
1065            metadata: {
1066                let mut metadata = ErrorMetadata::new();
1067                metadata.insert("config.kind", "sink_defaults");
1068                metadata
1069            },
1070            is_root_cause: true,
1071        };
1072
1073        let report_frame: SourceFrame = frame.clone().into();
1074        let roundtrip = SnapshotSourceFrame::from(report_frame);
1075
1076        assert_eq!(roundtrip, frame);
1077    }
1078
1079    #[cfg(feature = "serde_json")]
1080    #[test]
1081    fn test_to_stable_snapshot_json_uses_stable_export_shape() {
1082        let snapshot = ErrorSnapshot {
1083            reason: "system error".to_string(),
1084            detail: Some("outer detail".to_string()),
1085            position: None,
1086            want: Some("start engine".to_string()),
1087            path: Some("start engine".to_string()),
1088            context: vec![SnapshotContextFrame {
1089                target: Some("start engine".to_string()),
1090                action: Some("start engine".to_string()),
1091                locator: Some("engine.toml".to_string()),
1092                path: vec!["start engine".to_string()],
1093                metadata: ErrorMetadata::new(),
1094                fields: vec![("tenant".to_string(), "alpha".to_string())],
1095                result: crate::core::context::OperationResult::Fail,
1096            }],
1097            root_metadata: ErrorMetadata::new(),
1098            source_frames: vec![SnapshotSourceFrame {
1099                index: 0,
1100                message: "db unavailable".to_string(),
1101                display: Some("db unavailable".to_string()),
1102                type_name: Some("std::io::Error".to_string()),
1103                error_code: None,
1104                reason: None,
1105                want: Some("load config".to_string()),
1106                path: Some("load config / read".to_string()),
1107                detail: None,
1108                metadata: ErrorMetadata::new(),
1109                is_root_cause: true,
1110            }],
1111            category: ErrorCategory::Sys,
1112            code: "sys.test_error".to_string(),
1113        };
1114
1115        let json_value = snapshot.to_stable_snapshot_json().unwrap();
1116
1117        assert_eq!(
1118            json_value,
1119            serde_json::to_value(snapshot.stable_export()).unwrap()
1120        );
1121        assert_eq!(
1122            json_value["schema_version"],
1123            serde_json::json!(STABLE_SNAPSHOT_SCHEMA_VERSION)
1124        );
1125        assert_eq!(
1126            json_value["context"][0]["action"],
1127            serde_json::json!("start engine")
1128        );
1129        assert_eq!(
1130            json_value["context"][0]["locator"],
1131            serde_json::json!("engine.toml")
1132        );
1133        assert!(json_value["context"][0].get("fields").is_none());
1134        assert!(json_value["source_frames"][0].get("display").is_none());
1135    }
1136
1137    #[cfg(feature = "serde_json")]
1138    #[test]
1139    fn test_stable_snapshot_json_fields_match_schema_constants() {
1140        let snapshot = ErrorSnapshot {
1141            reason: "system error".to_string(),
1142            detail: Some("outer detail".to_string()),
1143            position: Some("src/main.rs:42".to_string()),
1144            want: Some("start engine".to_string()),
1145            path: Some("start engine".to_string()),
1146            context: vec![SnapshotContextFrame {
1147                target: Some("start engine".to_string()),
1148                action: None,
1149                locator: None,
1150                path: vec!["start engine".to_string()],
1151                metadata: ErrorMetadata::new(),
1152                fields: vec![("tenant".to_string(), "alpha".to_string())],
1153                result: crate::core::context::OperationResult::Fail,
1154            }],
1155            root_metadata: ErrorMetadata::new(),
1156            source_frames: vec![SnapshotSourceFrame {
1157                index: 0,
1158                message: "db unavailable".to_string(),
1159                display: Some("db unavailable".to_string()),
1160                type_name: Some("std::io::Error".to_string()),
1161                error_code: None,
1162                reason: None,
1163                want: Some("load config".to_string()),
1164                path: Some("load config / read".to_string()),
1165                detail: Some("inner detail".to_string()),
1166                metadata: ErrorMetadata::new(),
1167                is_root_cause: true,
1168            }],
1169            category: ErrorCategory::Sys,
1170            code: "sys.test_error".to_string(),
1171        };
1172
1173        let json_value = snapshot.to_stable_snapshot_json().unwrap();
1174        let top_level = json_value.as_object().unwrap();
1175        let context = json_value["context"][0].as_object().unwrap();
1176        let source_frame = json_value["source_frames"][0].as_object().unwrap();
1177
1178        assert_eq!(
1179            sorted_keys(top_level),
1180            sorted_strings(&[
1181                "schema_version",
1182                "reason",
1183                "detail",
1184                "position",
1185                "want",
1186                "path",
1187                "context",
1188                "root_metadata",
1189                "source_frames",
1190            ])
1191        );
1192        assert_eq!(
1193            sorted_keys(context),
1194            sorted_strings(&["target", "action", "locator", "path", "metadata"])
1195        );
1196        assert_eq!(
1197            sorted_keys(source_frame),
1198            sorted_strings(&[
1199                "index",
1200                "message",
1201                "error_code",
1202                "reason",
1203                "want",
1204                "path",
1205                "detail",
1206                "metadata",
1207                "is_root_cause",
1208            ])
1209        );
1210    }
1211
1212    #[cfg(feature = "serde_json")]
1213    fn sorted_keys(map: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
1214        let mut keys = map.keys().cloned().collect::<Vec<_>>();
1215        keys.sort();
1216        keys
1217    }
1218
1219    #[cfg(feature = "serde_json")]
1220    fn sorted_strings(values: &[&str]) -> Vec<String> {
1221        let mut values = values
1222            .iter()
1223            .map(|value| value.to_string())
1224            .collect::<Vec<_>>();
1225        values.sort();
1226        values
1227    }
1228}
1229
1230#[cfg(doc)]
1231mod stable_snapshot_compile_fail_docs {
1232    //! ```compile_fail
1233    //! use orion_error::{StableErrorSnapshot, ErrorSnapshot};
1234    //!
1235    //! fn must_not_compile(stable: StableErrorSnapshot) -> ErrorSnapshot {
1236    //!     stable.into()
1237    //! }
1238    //! ```
1239}